diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..64cd7d558 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "backend/pick-git/security"] + path = backend/pick-git/security + url = https://github.com/2021-pick-git/security.git + branch = main diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 000000000..73f69e095 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/2021-pick-git.iml b/.idea/2021-pick-git.iml new file mode 100644 index 000000000..bdf896d56 --- /dev/null +++ b/.idea/2021-pick-git.iml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 000000000..03d9549ea --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 000000000..9e2151782 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 000000000..c54f07482 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/uiDesigner.xml b/.idea/uiDesigner.xml new file mode 100644 index 000000000..e96534fb2 --- /dev/null +++ b/.idea/uiDesigner.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/pick-git/.gitignore b/backend/pick-git/.gitignore new file mode 100644 index 000000000..f21959cfe --- /dev/null +++ b/backend/pick-git/.gitignore @@ -0,0 +1,41 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Submodule ### +src/main/resources/application-security.yml +src/main/resources/application-prod.yml diff --git a/backend/pick-git/build.gradle b/backend/pick-git/build.gradle new file mode 100644 index 000000000..9e85e8c48 --- /dev/null +++ b/backend/pick-git/build.gradle @@ -0,0 +1,71 @@ +plugins { + id "org.asciidoctor.convert" version "1.5.9.2" + id 'org.springframework.boot' version '2.5.2' + id 'io.spring.dependency-management' version '1.0.11.RELEASE' + id 'java' +} + +group = 'com.woowacourse' +version = '0.0.1-SNAPSHOT' +sourceCompatibility = '11' + +repositories { + mavenCentral() +} + +dependencies { + implementation 'io.jsonwebtoken:jjwt:0.9.1' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.apache.httpcomponents:httpclient:4.5' + + compileOnly 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.12.4' + + runtimeOnly 'com.h2database:h2' + runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' + + asciidoctor 'org.springframework.restdocs:spring-restdocs-asciidoctor:1.2.6.RELEASE' + + 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' +} + +processResources.dependsOn('copySecurity') +processResources.dependsOn('copyProd') + +task copySecurity(type: Copy) { + from './security/application-security.yml' + into './src/main/resources' +} + +task copyProd(type: Copy) { + from './security/application-prod.yml' + into './src/main/resources' +} + +//restDocs setting +ext { + snippetsDir = file('build/generated-snippets') +} + +asciidoctor { + inputs.dir snippetsDir + dependsOn test +} + +task copyDocuments(type: Copy) { + dependsOn asciidoctor + + from file("build/asciidoc/html5/index.html") + into file("src/main/resources/static/docs") +} + +bootJar { + dependsOn copyDocuments +} + +test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/backend/pick-git/gradle/wrapper/gradle-wrapper.jar b/backend/pick-git/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..e708b1c02 Binary files /dev/null and b/backend/pick-git/gradle/wrapper/gradle-wrapper.jar differ diff --git a/backend/pick-git/gradle/wrapper/gradle-wrapper.properties b/backend/pick-git/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..442d9132e --- /dev/null +++ b/backend/pick-git/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.3-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/backend/pick-git/gradlew b/backend/pick-git/gradlew new file mode 100755 index 000000000..4f906e0c8 --- /dev/null +++ b/backend/pick-git/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/backend/pick-git/gradlew.bat b/backend/pick-git/gradlew.bat new file mode 100644 index 000000000..ac1b06f93 --- /dev/null +++ b/backend/pick-git/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/backend/pick-git/script/deploy_script.sh b/backend/pick-git/script/deploy_script.sh new file mode 100644 index 000000000..d13436d10 --- /dev/null +++ b/backend/pick-git/script/deploy_script.sh @@ -0,0 +1,92 @@ +#!/bin/bash + +if [ $# -eq 0 ] +then + echo "Usage: auto_build [options]" + echo " Options" + echo " -c (String) certificate" + echo " -p (int) port (default 22)" + echo " -h (String) host" + echo " -l (String) location (defualt /home/ubuntu))" + echo " -u (String) user (defualt ubuntu)" + echo " -d (String) deploy mode" + exit 1 +fi + +CERTIFICATE_PATH="" +PORT=22 +HOST="" +USER="ubuntu" +LOCATION="/home/ubuntu" +DEPLOY="" + +#parse options +while (( "$#" )); do + case "$1" in + -c|--certificate) + if [ -n "$2" ] && [ ${2:0:1} != "-" ]; then + CERTIFICATE_PATH=$2 + shift 2 + fi + ;; + -p|--port) + if [ -n "$2" ] && [ ${2:0:1} != "-" ]; then + PORT=$2 + shift 2 + fi + ;; + -h|--host) + if [ -n "$2" ] && [ ${2:0:1} != "-" ]; then + HOST=$2 + shift 2 + fi + ;; + -l|--location) + if [ -n "$2" ] && [ ${2:0:1} != "-" ]; then + LOCATION=$2 + shift 2 + fi + ;; + -u|--user) + if [ -n "$2" ] && [ ${2:0:1} != "-" ]; then + USER=$2 + fi + ;; + -d|--deploy) + if [ -n "$2" ] && [ ${2:0:1} != "-" ]; then + DEPLOY=$2 + shift 2 + fi + ;; + esac +done + +if [ ! -f "$CERTIFICATE_PATH" ]; then + echo "Error: certificate is not exist" + exit 1 +fi + +if [ -z $HOST ]; then + echo "Error: host is required" + exit 1 +fi + +if [ -z $DEPLOY ]; then + echo "Error: deploy option is required" + exit 1 +fi + +##remove plain jar +rm ../build/libs/*plain*.jar + +#migration +JAR_PATH=$(find ../build/libs -name "*.jar") +scp -i $CERTIFICATE_PATH $JAR_PATH $USER@$HOST:$LOCATION + +JAR_NAME=${JAR_PATH##*/} + +ssh -i $CERTIFICATE_PATH -l $USER $HOST "PID=\$(ps -p \$(lsof -ti tcp:8080) o pid=); kill -9 \$PID; sleep 5; nohup java -Dspring.profiles.active=$DEPLOY -jar $LOCATION/$JAR_NAME > pickgit.out 2> pickgit.err < /dev/null &" + + + +echo "deploy finished" diff --git a/backend/pick-git/security b/backend/pick-git/security new file mode 160000 index 000000000..33ba58ad7 --- /dev/null +++ b/backend/pick-git/security @@ -0,0 +1 @@ +Subproject commit 33ba58ad7639111b51e8773bf0cd54175680f985 diff --git a/backend/pick-git/settings.gradle b/backend/pick-git/settings.gradle new file mode 100644 index 000000000..f8939069b --- /dev/null +++ b/backend/pick-git/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'pick-git' diff --git a/backend/pick-git/src/docs/asciidoc/authorization.adoc b/backend/pick-git/src/docs/asciidoc/authorization.adoc new file mode 100644 index 000000000..b19114434 --- /dev/null +++ b/backend/pick-git/src/docs/asciidoc/authorization.adoc @@ -0,0 +1,21 @@ +ifndef::snippets[] +:snippets: ../../../build/generated-snippets +endif::[] +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 4 + +== Authorization +=== 깃허브 로그인 url 받기 +==== Request +include::{snippets}/authorization - githubLogin/http-request.adoc[] +==== Response +include::{snippets}/authorization - githubLogin/http-response.adoc[] + +=== 깃허브 로그인 후 +==== Request +include::{snippets}/authorization - afterlogin/http-request.adoc[] +==== Response +include::{snippets}/authorization - afterlogin/http-response.adoc[] \ No newline at end of file diff --git a/backend/pick-git/src/docs/asciidoc/comment.adoc b/backend/pick-git/src/docs/asciidoc/comment.adoc new file mode 100644 index 000000000..1ecaf6780 --- /dev/null +++ b/backend/pick-git/src/docs/asciidoc/comment.adoc @@ -0,0 +1,27 @@ +ifndef::snippets[] +:snippets: ../../../build/generated-snippets +endif::[] +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 4 + +== Comment +=== 댓글 등록 +==== Request +include::{snippets}/comment-post/http-request.adoc[] +==== Response +include::{snippets}/comment-post/http-response.adoc[] + +=== 댓글 등록 - 내용이 없는 경우 +==== Request +include::{snippets}/comment-post-emptyContent/http-request.adoc[] +==== Response +include::{snippets}/comment-post-emptyContent/http-response.adoc[] + +=== 댓글 등록 - 내용이 없는 경우 +==== Request +include::{snippets}/comment-post-emptyContent/http-request.adoc[] +==== Response +include::{snippets}/comment-post-emptyContent/http-response.adoc[] \ No newline at end of file diff --git a/backend/pick-git/src/docs/asciidoc/following.adoc b/backend/pick-git/src/docs/asciidoc/following.adoc new file mode 100644 index 000000000..5f369bcd0 --- /dev/null +++ b/backend/pick-git/src/docs/asciidoc/following.adoc @@ -0,0 +1,33 @@ +ifndef::snippets[] +:snippets: ../../../build/generated-snippets +endif::[] +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 4 + +== Authorization +=== 팔로잉 요청 - 로그인 +==== Request +include::{snippets}/following-LoggedIn/http-request.adoc[] +==== Response +include::{snippets}/following-LoggedIn/http-response.adoc[] + +=== 언팔로우 요청 - 로그인 +==== Request +include::{snippets}/unfollowing-LoggedIn/http-request.adoc[] +==== Response +include::{snippets}/unfollowing-LoggedIn/http-response.adoc[] + +=== 팔로잉 요청 - 비 로그인 +==== Request +include::{snippets}/following-unLoggedIn/http-request.adoc[] +==== Response +include::{snippets}/following-unLoggedIn/http-response.adoc[] + +=== 언팔로우 요청 - 비 로그인 +==== Request +include::{snippets}/unfollowing-unLoggedIn/http-request.adoc[] +==== Response +include::{snippets}/unfollowing-unLoggedIn/http-response.adoc[] \ No newline at end of file diff --git a/backend/pick-git/src/docs/asciidoc/index.adoc b/backend/pick-git/src/docs/asciidoc/index.adoc new file mode 100644 index 000000000..5a7939d16 --- /dev/null +++ b/backend/pick-git/src/docs/asciidoc/index.adoc @@ -0,0 +1,17 @@ +ifndef::snippets[] +:snippets: ../../../build/generated-snippets +endif::[] +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 4 + +include::authorization.adoc[] +include::comment.adoc[] +include::following.adoc[] +include::post.adoc[] +include::profile.adoc[] +include::tag.adoc[] + +PickGit API \ No newline at end of file diff --git a/backend/pick-git/src/docs/asciidoc/post.adoc b/backend/pick-git/src/docs/asciidoc/post.adoc new file mode 100644 index 000000000..74fec1b8c --- /dev/null +++ b/backend/pick-git/src/docs/asciidoc/post.adoc @@ -0,0 +1,39 @@ +ifndef::snippets[] +:snippets: ../../../build/generated-snippets +endif::[] +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 4 + +== Post +=== 게시물 작성 - 로그인 +==== Request +include::{snippets}/posts-post-user/http-request.adoc[] +==== Response +include::{snippets}/posts-post-user/http-response.adoc[] + +=== 게시물 작성 - 비 로그인 +==== Request +include::{snippets}/posts-post-guest/http-request.adoc[] +==== Response +include::{snippets}/posts-post-guest/http-response.adoc[] + +=== 홈 피드 요청 - 로그인 +==== Request +include::{snippets}/post-homefeed-LoggedIn/http-request.adoc[] +==== Response +include::{snippets}/post-homefeed-LoggedIn/http-response.adoc[] + +=== 홈 피드 요청 - 비 로그인 +==== Request +include::{snippets}/post-homefeed-unLoggedIn/http-request.adoc[] +==== Response +include::{snippets}/post-homefeed-unLoggedIn/http-response.adoc[] + +=== 레포지토리 요청 - 로그인 +==== Request +include::{snippets}/repositories-loggedIn/http-request.adoc[] +==== Response +include::{snippets}/repositories-loggedIn/http-response.adoc[] \ No newline at end of file diff --git a/backend/pick-git/src/docs/asciidoc/profile.adoc b/backend/pick-git/src/docs/asciidoc/profile.adoc new file mode 100644 index 000000000..b96b72edc --- /dev/null +++ b/backend/pick-git/src/docs/asciidoc/profile.adoc @@ -0,0 +1,27 @@ +ifndef::snippets[] +:snippets: ../../../build/generated-snippets +endif::[] +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 4 + +== Profile +=== 내 프로필 조회 +==== Request +include::{snippets}/profilesMe/http-request.adoc[] +==== Response +include::{snippets}/profilesMe/http-response.adoc[] + +=== 다른 사용자 프로필 조회 - 로그인 +==== Request +include::{snippets}/profiles-LoggedIn/http-request.adoc[] +==== Response +include::{snippets}/profiles-LoggedIn/http-response.adoc[] + +=== 다른 사용자 프로필 조회 - 비 로그인 +==== Request +include::{snippets}/profiles-unLoggedIn/http-request.adoc[] +==== Response +include::{snippets}/profiles-unLoggedIn/http-response.adoc[] \ No newline at end of file diff --git a/backend/pick-git/src/docs/asciidoc/tag.adoc b/backend/pick-git/src/docs/asciidoc/tag.adoc new file mode 100644 index 000000000..570b11e34 --- /dev/null +++ b/backend/pick-git/src/docs/asciidoc/tag.adoc @@ -0,0 +1,27 @@ +ifndef::snippets[] +:snippets: ../../../build/generated-snippets +endif::[] +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 4 + +== Tag +=== 특정 유저의 태그 목록 요청 +==== Request +include::{snippets}/tag-extractTagFromRepositoryOfSpecificUser/http-request.adoc[] +==== Response +include::{snippets}/tag-extractTagFromRepositoryOfSpecificUser/http-response.adoc[] + +=== 유효하지 않은 AccessToken으로 태그 추출 요청 +==== Request +include::{snippets}/tags-invalidToken/http-request.adoc[] +==== Response +include::{snippets}/tags-invalidToken/http-response.adoc[] + +=== 유효하지 않은 레포지토리 태그 추출 요청 +==== Request +include::{snippets}/tags-invalidRepository/http-request.adoc[] +==== Response +include::{snippets}/tags-invalidRepository/http-response.adoc[] \ No newline at end of file diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/PickGitApplication.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/PickGitApplication.java new file mode 100644 index 000000000..c45f25e97 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/PickGitApplication.java @@ -0,0 +1,13 @@ +package com.woowacourse.pickgit; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class PickGitApplication { + + public static void main(String[] args) { + SpringApplication.run(PickGitApplication.class, args); + } + +} 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 new file mode 100644 index 000000000..3c53aca99 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/application/JwtTokenProvider.java @@ -0,0 +1,14 @@ +package com.woowacourse.pickgit.authentication.application; + +import com.woowacourse.pickgit.user.domain.User; + +public interface JwtTokenProvider { + + String createToken(String payload); + + boolean validateToken(String token); + + String getPayloadByKey(String token, String key); + + void changeExpirationTime(long expirationTimeInMilliSeconds); +} 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 new file mode 100644 index 000000000..83f1dea8e --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/application/OAuthService.java @@ -0,0 +1,89 @@ +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.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.exception.authentication.InvalidTokenException; +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 org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class OAuthService { + + private OAuthClient githubOAuthClient; + private JwtTokenProvider jwtTokenProvider; + private OAuthAccessTokenDao authAccessTokenDao; + private UserRepository userRepository; + + public OAuthService(OAuthClient githubOAuthClient, + JwtTokenProvider jwtTokenProvider, + OAuthAccessTokenDao authAccessTokenDao, + UserRepository userRepository) { + this.githubOAuthClient = githubOAuthClient; + this.jwtTokenProvider = jwtTokenProvider; + this.authAccessTokenDao = authAccessTokenDao; + this.userRepository = userRepository; + } + + public String getGithubAuthorizationUrl() { + return githubOAuthClient.getLoginUrl(); + } + + @Transactional + public TokenDto createToken(String code) { + String githubAccessToken = githubOAuthClient.getAccessToken(code); + + OAuthProfileResponse githubProfileResponse = githubOAuthClient.getGithubProfile(githubAccessToken); + + updateUserOrCreateUser(githubProfileResponse); + + return new TokenDto(createTokenAndSave( + githubAccessToken, + githubProfileResponse.getName()), + githubProfileResponse.getName() + ); + } + + private void updateUserOrCreateUser(OAuthProfileResponse githubProfileResponse) { + GithubProfile latestGithubProfile = githubProfileResponse.toGithubProfile(); + + userRepository.findByBasicProfile_Name(githubProfileResponse.getName()) + .ifPresentOrElse(user -> { + user.changeGithubProfile(latestGithubProfile); + }, () -> { + BasicProfile basicProfile = githubProfileResponse.toBasicProfile(); + User user = new User(basicProfile, latestGithubProfile); + userRepository.save(user); + }); + } + + private String createTokenAndSave(String githubAccessToken, String payload) { + String token = jwtTokenProvider.createToken(payload); + authAccessTokenDao.insert(token, githubAccessToken); + return token; + } + + @Transactional(readOnly = true) + public AppUser findRequestUserByToken(String authentication) { + if (authentication == null) { + return new GuestUser(); + } + + String username = jwtTokenProvider.getPayloadByKey(authentication, "username"); + String accessToken = authAccessTokenDao.findByKeyToken(authentication) + .orElseThrow(() -> new InvalidTokenException()); + return new LoginUser(username, accessToken); + } + + public boolean validateToken(String authentication) { + return jwtTokenProvider.validateToken(authentication); + } +} 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 new file mode 100644 index 000000000..cc2fff319 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/application/dto/OAuthProfileResponse.java @@ -0,0 +1,127 @@ +package com.woowacourse.pickgit.authentication.application.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.woowacourse.pickgit.user.domain.profile.BasicProfile; +import com.woowacourse.pickgit.user.domain.profile.GithubProfile; + +public class OAuthProfileResponse { + + @JsonProperty("login") + private String name; + + @JsonProperty("avatar_url") + private String image; + + @JsonProperty("bio") + private String description; + + @JsonProperty("html_url") + private String githubUrl; + + private String company; + + private String location; + + @JsonProperty("blog") + private String website; + + @JsonProperty("twitter_username") + private String twitter; + + public OAuthProfileResponse() { + } + + public OAuthProfileResponse(String name, String image, String description, + String githubUrl, String company, String location, String website, String twitter) { + this.name = name; + this.image = image; + this.description = description; + this.githubUrl = githubUrl; + this.company = company; + this.location = location; + this.website = website; + 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, + company, + location, + website, + twitter + ); + } + + public BasicProfile toBasicProfile() { + return new BasicProfile( + name, + image, + description + ); + } + + public void setImage(String image) { + this.image = image; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = 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/application/dto/TokenDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/application/dto/TokenDto.java new file mode 100644 index 000000000..157e3a1c1 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/application/dto/TokenDto.java @@ -0,0 +1,31 @@ +package com.woowacourse.pickgit.authentication.application.dto; + +public class TokenDto { + + private String token; + private String username; + + private TokenDto() { + } + + public TokenDto(String token, String username) { + this.token = token; + this.username = username; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/dao/CollectionOAuthAccessTokenDao.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/dao/CollectionOAuthAccessTokenDao.java new file mode 100644 index 000000000..d038b414a --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/dao/CollectionOAuthAccessTokenDao.java @@ -0,0 +1,19 @@ +package com.woowacourse.pickgit.authentication.dao; + +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.stereotype.Component; + +@Component +public class CollectionOAuthAccessTokenDao implements OAuthAccessTokenDao { + + private final ConcurrentHashMap tokenDb = new ConcurrentHashMap<>(); + + public void insert(String token, String oauthAccessToken) { + tokenDb.put(token, oauthAccessToken); + } + + public Optional findByKeyToken(String token) { + return Optional.ofNullable(tokenDb.get(token)); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/dao/OAuthAccessTokenDao.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/dao/OAuthAccessTokenDao.java new file mode 100644 index 000000000..4359c9235 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/dao/OAuthAccessTokenDao.java @@ -0,0 +1,10 @@ +package com.woowacourse.pickgit.authentication.dao; + +import java.util.Optional; + +public interface OAuthAccessTokenDao { + + void insert(String token, String oauthAccessToken); + + Optional findByKeyToken(String token); +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/domain/Authenticated.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/domain/Authenticated.java new file mode 100644 index 000000000..8614ad9f2 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/domain/Authenticated.java @@ -0,0 +1,11 @@ +package com.woowacourse.pickgit.authentication.domain; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface Authenticated { +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/domain/OAuthClient.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/domain/OAuthClient.java new file mode 100644 index 000000000..2ee25f6f7 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/domain/OAuthClient.java @@ -0,0 +1,12 @@ +package com.woowacourse.pickgit.authentication.domain; + +import com.woowacourse.pickgit.authentication.application.dto.OAuthProfileResponse; + +public interface OAuthClient { + + String getLoginUrl(); + + String getAccessToken(String code); + + OAuthProfileResponse getGithubProfile(String githubAccessToken); +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/domain/user/AppUser.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/domain/user/AppUser.java new file mode 100644 index 000000000..51609f228 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/domain/user/AppUser.java @@ -0,0 +1,22 @@ +package com.woowacourse.pickgit.authentication.domain.user; + +public abstract class AppUser { + + private String username; + private String accessToken; + + public AppUser(String username, String accessToken) { + this.username = username; + this.accessToken = accessToken; + } + + public String getUsername() { + return username; + } + + public String getAccessToken() { + return accessToken; + } + + public abstract boolean isGuest(); +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/domain/user/GuestUser.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/domain/user/GuestUser.java new file mode 100644 index 000000000..0ef1f0997 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/domain/user/GuestUser.java @@ -0,0 +1,28 @@ +package com.woowacourse.pickgit.authentication.domain.user; + +import com.woowacourse.pickgit.exception.authentication.UnauthorizedException; + +public class GuestUser extends AppUser { + + private static final String DUMMY_USERNAME = "anonymous"; + private static final String DUMMY_ACCESSTOKEN = "anonymous"; + + public GuestUser() { + super(DUMMY_USERNAME, DUMMY_ACCESSTOKEN); + } + + @Override + public boolean isGuest() { + return true; + } + + @Override + public String getUsername() { + throw new UnauthorizedException(); + } + + @Override + public String getAccessToken() { + throw new UnauthorizedException(); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/domain/user/LoginUser.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/domain/user/LoginUser.java new file mode 100644 index 000000000..a1d0dad4a --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/domain/user/LoginUser.java @@ -0,0 +1,13 @@ +package com.woowacourse.pickgit.authentication.domain.user; + +public class LoginUser extends AppUser { + + public LoginUser(String username, String accessToken) { + super(username, accessToken); + } + + @Override + public boolean isGuest() { + return false; + } +} 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 new file mode 100644 index 000000000..1ebe1e31f --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/infrastructure/AuthorizationExtractor.java @@ -0,0 +1,29 @@ +package com.woowacourse.pickgit.authentication.infrastructure; + +import java.util.Enumeration; +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"; + + public static String extract(HttpServletRequest request) { + Enumeration headers = request.getHeaders(AUTHORIZATION); + while (headers.hasMoreElements()) { + String value = headers.nextElement(); + if ((value.toLowerCase().startsWith(BEARER_TYPE.toLowerCase()))) { + String authHeaderValue = value.substring(BEARER_TYPE.length()).trim(); + request.setAttribute(ACCESS_TOKEN_TYPE, value.substring(0, BEARER_TYPE.length()).trim()); + int commaIndex = authHeaderValue.indexOf(','); + if (commaIndex > 0) { + authHeaderValue = authHeaderValue.substring(0, commaIndex); + } + return authHeaderValue; + } + } + + 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 new file mode 100644 index 000000000..118bfa9b3 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/infrastructure/GithubOAuthClient.java @@ -0,0 +1,71 @@ +package com.woowacourse.pickgit.authentication.infrastructure; + +import com.woowacourse.pickgit.authentication.application.dto.OAuthProfileResponse; +import com.woowacourse.pickgit.authentication.domain.OAuthClient; +import com.woowacourse.pickgit.authentication.infrastructure.dto.OAuthAccessTokenRequest; +import com.woowacourse.pickgit.authentication.infrastructure.dto.OAuthAccessTokenResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +@Component +public class GithubOAuthClient implements OAuthClient { + + @Value("${security.github.client.id}") + private String clientId; + + @Value("${security.github.client.secret}") + private String clientSecret; + + @Value("${security.github.url.redirect}") + private String redirectUrl; + + @Value("${security.github.url.access-token}") + private String accessTokenUrl; + + @Override + public String getLoginUrl() { + return "https://github.com/login/oauth/authorize?" + + "client_id=" + clientId + + "&redirect_url=" + redirectUrl; + } + + @Override + public String getAccessToken(String code) { + OAuthAccessTokenRequest githubAccessTokenRequest = new OAuthAccessTokenRequest(clientId, clientSecret, code); + HttpHeaders headers = new HttpHeaders(); + headers.add("Accept", MediaType.APPLICATION_JSON_VALUE); + + HttpEntity> httpEntity = new HttpEntity(githubAccessTokenRequest, headers); + + RestTemplate restTemplate = new RestTemplate(); + String accessToken = restTemplate + .exchange(accessTokenUrl, HttpMethod.POST, httpEntity, OAuthAccessTokenResponse.class) + .getBody() + .getAccessToken(); + + if (accessToken == null) { + throw new IllegalArgumentException("깃헙 인증 에러"); + } + return accessToken; + } + + @Override + public OAuthProfileResponse getGithubProfile(String githubAccessToken) { + HttpHeaders headers = new HttpHeaders(); + headers.add("Accept", MediaType.APPLICATION_JSON_VALUE); + headers.add("Authorization", "Bearer " + githubAccessToken); + + HttpEntity> httpEntity = new HttpEntity(headers); + + RestTemplate restTemplate = new RestTemplate(); + return restTemplate + .exchange("https://api.github.com/user", HttpMethod.GET, httpEntity, OAuthProfileResponse.class) + .getBody(); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/infrastructure/JwtTokenProviderImpl.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/infrastructure/JwtTokenProviderImpl.java new file mode 100644 index 000000000..79daa5b36 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/infrastructure/JwtTokenProviderImpl.java @@ -0,0 +1,67 @@ +package com.woowacourse.pickgit.authentication.infrastructure; + +import com.woowacourse.pickgit.authentication.application.JwtTokenProvider; +import com.woowacourse.pickgit.exception.authentication.InvalidTokenException; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import java.util.Date; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class JwtTokenProviderImpl implements JwtTokenProvider { + + @Value("${security.jwt.secret-key}") + private String secretKey; + @Value("${security.jwt.expiration-time}") + private long expirationTimeInMilliSeconds; + + public JwtTokenProviderImpl() { + } + + public JwtTokenProviderImpl(String secretKey, long expirationTimeInMilliSeconds) { + this.secretKey = secretKey; + this.expirationTimeInMilliSeconds = expirationTimeInMilliSeconds; + } + + @Override + public String createToken(String payload) { + Date now = new Date(); + Date expirationTime = new Date(now.getTime() + expirationTimeInMilliSeconds); + + return Jwts.builder() + .claim("username", payload) + .setIssuedAt(now) + .setExpiration(expirationTime) + .signWith(SignatureAlgorithm.HS256, secretKey) + .compact(); + } + + @Override + public boolean validateToken(String token) { + try { + Jws claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token); + + return !claims.getBody().getExpiration().before(new Date()); + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + @Override + public String getPayloadByKey(String token, String key) { + try { + return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().get(key, String.class); + } catch (JwtException | IllegalArgumentException e) { + throw new InvalidTokenException(); + } + } + + @Override + public void changeExpirationTime(long expirationTimeInMilliSeconds) { + this.expirationTimeInMilliSeconds = expirationTimeInMilliSeconds; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/infrastructure/dto/OAuthAccessTokenRequest.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/infrastructure/dto/OAuthAccessTokenRequest.java new file mode 100644 index 000000000..d4e98a389 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/infrastructure/dto/OAuthAccessTokenRequest.java @@ -0,0 +1,29 @@ +package com.woowacourse.pickgit.authentication.infrastructure.dto; + +public class OAuthAccessTokenRequest { + + private String client_id; + private String client_secret; + private String code; + + public OAuthAccessTokenRequest() { + } + + public OAuthAccessTokenRequest(String client_id, String client_secret, String code) { + this.client_id = client_id; + this.client_secret = client_secret; + this.code = code; + } + + public String getClient_id() { + return client_id; + } + + public String getClient_secret() { + return client_secret; + } + + public String getCode() { + return code; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/infrastructure/dto/OAuthAccessTokenResponse.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/infrastructure/dto/OAuthAccessTokenResponse.java new file mode 100644 index 000000000..181a4a228 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/infrastructure/dto/OAuthAccessTokenResponse.java @@ -0,0 +1,40 @@ +package com.woowacourse.pickgit.authentication.infrastructure.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class OAuthAccessTokenResponse { + + @JsonProperty("access_token") + private String accessToken; + @JsonProperty("token_type") + private String tokenType; + private String scope; + private String bearer; + + public OAuthAccessTokenResponse() { + } + + public OAuthAccessTokenResponse(String accessToken, String tokenType, String scope, + String bearer) { + this.accessToken = accessToken; + this.tokenType = tokenType; + this.scope = scope; + this.bearer = bearer; + } + + public String getAccessToken() { + return accessToken; + } + + public String getTokenType() { + return tokenType; + } + + public String getScope() { + return scope; + } + + public String getBearer() { + return bearer; + } +} 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 new file mode 100644 index 000000000..d27449201 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/presentation/AuthenticationPrincipalArgumentResolver.java @@ -0,0 +1,36 @@ +package com.woowacourse.pickgit.authentication.presentation; + +import com.woowacourse.pickgit.authentication.application.OAuthService; +import com.woowacourse.pickgit.authentication.domain.Authenticated; +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; +import org.springframework.web.method.support.ModelAndViewContainer; + +public class AuthenticationPrincipalArgumentResolver implements HandlerMethodArgumentResolver { + + private final OAuthService oAuthService; + + public AuthenticationPrincipalArgumentResolver( + OAuthService oAuthService) { + this.oAuthService = oAuthService; + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(Authenticated.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); + String authentication = (String) request.getAttribute(AuthHeader.AUTHENTICATION); + + return oAuthService.findRequestUserByToken(authentication); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/presentation/OAuthController.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/presentation/OAuthController.java new file mode 100644 index 000000000..c63a258df --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/presentation/OAuthController.java @@ -0,0 +1,39 @@ +package com.woowacourse.pickgit.authentication.presentation; + +import com.woowacourse.pickgit.authentication.application.OAuthService; +import com.woowacourse.pickgit.authentication.application.dto.TokenDto; +import com.woowacourse.pickgit.authentication.presentation.dto.OAuthLoginUrlResponse; +import com.woowacourse.pickgit.authentication.presentation.dto.OAuthTokenResponse; +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 OAuthController { + + private final OAuthService oauthService; + + public OAuthController(OAuthService oauthService) { + this.oauthService = oauthService; + } + + @GetMapping("/authorization/github") + public ResponseEntity githubAuthorizationUrl() { + return ResponseEntity + .ok() + .body(new OAuthLoginUrlResponse(oauthService.getGithubAuthorizationUrl())); + } + + @GetMapping("/afterlogin") + public ResponseEntity afterAuthorizeGithubLogin(@RequestParam String code) { + TokenDto tokenDto = oauthService.createToken(code); + return ResponseEntity + .ok() + .body(new OAuthTokenResponse(tokenDto.getToken(), tokenDto.getUsername())); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/presentation/dto/OAuthLoginUrlResponse.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/presentation/dto/OAuthLoginUrlResponse.java new file mode 100644 index 000000000..dd5dc3c8f --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/presentation/dto/OAuthLoginUrlResponse.java @@ -0,0 +1,21 @@ +package com.woowacourse.pickgit.authentication.presentation.dto; + +public class OAuthLoginUrlResponse { + + private String url; + + public OAuthLoginUrlResponse() { + } + + public OAuthLoginUrlResponse(String url) { + this.url = url; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/presentation/dto/OAuthTokenResponse.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/presentation/dto/OAuthTokenResponse.java new file mode 100644 index 000000000..cc010d79c --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/presentation/dto/OAuthTokenResponse.java @@ -0,0 +1,31 @@ +package com.woowacourse.pickgit.authentication.presentation.dto; + +public class OAuthTokenResponse { + + private String token; + private String username; + + public OAuthTokenResponse() { + } + + public OAuthTokenResponse(String token, String username) { + this.token = token; + this.username = username; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/presentation/interceptor/AuthHeader.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/presentation/interceptor/AuthHeader.java new file mode 100644 index 000000000..b09fff85a --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/presentation/interceptor/AuthHeader.java @@ -0,0 +1,6 @@ +package com.woowacourse.pickgit.authentication.presentation.interceptor; + +public interface AuthHeader { + + String AUTHENTICATION = "Authentication"; +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/presentation/interceptor/AuthenticationInterceptor.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/presentation/interceptor/AuthenticationInterceptor.java new file mode 100644 index 000000000..11dcb5ea2 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/presentation/interceptor/AuthenticationInterceptor.java @@ -0,0 +1,60 @@ +package com.woowacourse.pickgit.authentication.presentation.interceptor; + +import com.woowacourse.pickgit.authentication.application.OAuthService; +import com.woowacourse.pickgit.authentication.infrastructure.AuthorizationExtractor; +import com.woowacourse.pickgit.exception.authentication.InvalidTokenException; +import java.util.Objects; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +public class AuthenticationInterceptor implements HandlerInterceptor { + + private final OAuthService oAuthService; + + public AuthenticationInterceptor( + OAuthService oAuthService) { + this.oAuthService = oAuthService; + } + + @Override + public boolean preHandle( + HttpServletRequest request, + HttpServletResponse response, + Object handler) throws Exception { + if (isPreflightRequest(request)) { + return true; + } + String authentication = AuthorizationExtractor.extract(request); + if (!oAuthService.validateToken(authentication)) { + throw new InvalidTokenException(); + } + request.setAttribute(AuthHeader.AUTHENTICATION, authentication); + return true; + } + + private boolean isPreflightRequest(HttpServletRequest request) { + return isOptions(request) + && hasAccessControlRequestHeaders(request) + && hasAccessControlRequestMethod(request) + && hasOrigin(request); + } + + private boolean isOptions(HttpServletRequest request) { + return request.getMethod().equalsIgnoreCase(HttpMethod.OPTIONS.toString()); + } + + private boolean hasAccessControlRequestHeaders(HttpServletRequest request) { + return Objects.nonNull(request.getHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS)); + } + + private boolean hasAccessControlRequestMethod(HttpServletRequest request) { + return Objects.nonNull(request.getHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD)); + } + + private boolean hasOrigin(HttpServletRequest request) { + return Objects.nonNull(request.getHeader(HttpHeaders.ORIGIN)); + } +} 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 new file mode 100644 index 000000000..49bead99e --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/presentation/interceptor/IgnoreAuthenticationInterceptor.java @@ -0,0 +1,59 @@ +package com.woowacourse.pickgit.authentication.presentation.interceptor; + +import com.woowacourse.pickgit.authentication.application.OAuthService; +import com.woowacourse.pickgit.authentication.infrastructure.AuthorizationExtractor; +import com.woowacourse.pickgit.exception.authentication.InvalidTokenException; +import java.util.Objects; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +public class IgnoreAuthenticationInterceptor implements HandlerInterceptor { + + private OAuthService oAuthService; + + public IgnoreAuthenticationInterceptor( + OAuthService oAuthService) { + this.oAuthService = oAuthService; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, + Object handler) throws Exception { + if (isPreflightRequest(request)) { + return true; + } + + String authentication = AuthorizationExtractor.extract(request); + if (Objects.nonNull(authentication) && !oAuthService.validateToken(authentication)) { + throw new InvalidTokenException(); + } + request.setAttribute(AuthHeader.AUTHENTICATION, authentication); + return true; + } + + private boolean isPreflightRequest(HttpServletRequest request) { + return isOptions(request) + && hasAccessControlRequestHeaders(request) + && hasAccessControlRequestMethod(request) + && hasOrigin(request); + } + + private boolean isOptions(HttpServletRequest request) { + return request.getMethod().equalsIgnoreCase(HttpMethod.OPTIONS.toString()); + } + + private boolean hasAccessControlRequestHeaders(HttpServletRequest request) { + return Objects.nonNull(request.getHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS)); + } + + private boolean hasAccessControlRequestMethod(HttpServletRequest request) { + return Objects.nonNull(request.getHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD)); + } + + private boolean hasOrigin(HttpServletRequest request) { + return Objects.nonNull(request.getHeader(HttpHeaders.ORIGIN)); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/presentation/interceptor/PathMatchInterceptor.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/presentation/interceptor/PathMatchInterceptor.java new file mode 100644 index 000000000..c89216ace --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/presentation/interceptor/PathMatchInterceptor.java @@ -0,0 +1,86 @@ +package com.woowacourse.pickgit.authentication.presentation.interceptor; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.springframework.http.HttpMethod; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.PathMatcher; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.ModelAndView; + +public class PathMatchInterceptor implements HandlerInterceptor { + + private HandlerInterceptor handlerInterceptor; + private HashMap> includeRegistry; + private HashMap> excludeRegistry; + private PathMatcher pathMatcher; + + public PathMatchInterceptor(HandlerInterceptor handlerInterceptor) { + this.handlerInterceptor = handlerInterceptor; + this.pathMatcher = new AntPathMatcher(); + this.includeRegistry = new HashMap<>(); + this.excludeRegistry = new HashMap<>(); + } + + public PathMatchInterceptor addPathPatterns(String pattern, HttpMethod... methods) { + return addPathPatterns(pattern, Arrays.asList(methods)); + } + + public PathMatchInterceptor addPathPatterns(String pattern, List methods) { + this.includeRegistry.putIfAbsent(pattern, methods); + return this; + } + + public PathMatchInterceptor excludePatterns(String pattern, List methods) { + this.excludeRegistry.putIfAbsent(pattern, methods); + return this; + } + + public PathMatchInterceptor excludePatterns(String pattern, HttpMethod... methods) { + return excludePatterns(pattern, Arrays.asList(methods)); + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, + Object handler) throws Exception { + String requestUrl = request.getRequestURI(); + String requestMethod = request.getMethod(); + + for (String url : excludeRegistry.keySet()) { + boolean isMethodMatch = excludeRegistry.get(url) + .stream() + .map(Enum::name) + .anyMatch(method -> method.equals(requestMethod)); + if (pathMatcher.match(url, requestUrl) && isMethodMatch) { + return true; + } + } + + for (String url : includeRegistry.keySet()) { + boolean isMethodMatch = includeRegistry.get(url) + .stream() + .map(Enum::name) + .anyMatch(method -> method.equals(requestMethod)); + if (pathMatcher.match(url, requestUrl) && isMethodMatch) { + return handlerInterceptor.preHandle(request, response, handler); + } + } + + return true; + } + + @Override + public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, + ModelAndView modelAndView) throws Exception { + HandlerInterceptor.super.postHandle(request, response, handler, modelAndView); + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, + Object handler, Exception ex) throws Exception { + HandlerInterceptor.super.afterCompletion(request, response, handler, ex); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/common/empty.txt b/backend/pick-git/src/main/java/com/woowacourse/pickgit/common/empty.txt new file mode 100644 index 000000000..e69de29bb 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 new file mode 100644 index 000000000..a915f7b8b --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/common/network/RestTemplateClient.java @@ -0,0 +1,288 @@ +package com.woowacourse.pickgit.common.network; + +import com.woowacourse.pickgit.post.infrastructure.RestClient; +import java.net.URI; +import java.util.Map; +import java.util.Set; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +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.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RequestCallback; +import org.springframework.web.client.ResponseExtractor; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +@Component +public class RestTemplateClient implements RestClient { + + private static final int READ_TIMEOUT = 5000; + private static final int CONNECTION_REQUEST_TIMEOUT = 3000; + private static final int MAX_CONN_TOTAL = 100; + private static final int MAX_CONN_PER_ROUTE = 50; + + public final RestTemplate restTemplate = createRestTemplate(); + + private static RestTemplate createRestTemplate() { + var factory = new HttpComponentsClientHttpRequestFactory(); + factory.setReadTimeout(READ_TIMEOUT); + factory.setConnectionRequestTimeout(CONNECTION_REQUEST_TIMEOUT); + + CloseableHttpClient httpClient = HttpClientBuilder.create() + .setMaxConnTotal(MAX_CONN_TOTAL) + .setMaxConnPerRoute(MAX_CONN_PER_ROUTE) + .build(); + + factory.setHttpClient(httpClient); + + return new RestTemplate(factory); + } + + @Override + public T getForObject(String url, Class responseType, Object... uriVariables) + throws RestClientException { + return this.restTemplate.getForObject(url, responseType, uriVariables); + } + + @Override + public T getForObject(String url, Class responseType, Map uriVariables) + throws RestClientException { + return this.restTemplate.getForObject(url, responseType, uriVariables); + } + + @Override + public T getForObject(URI url, Class responseType) throws RestClientException { + return this.restTemplate.getForObject(url, responseType); + } + + @Override + public ResponseEntity getForEntity(String url, Class responseType, + Object... uriVariables) throws RestClientException { + return this.restTemplate.getForEntity(url, responseType, uriVariables); + } + + @Override + public ResponseEntity getForEntity(String url, Class responseType, + Map uriVariables) throws RestClientException { + return this.restTemplate.getForEntity(url, responseType, uriVariables); + } + + @Override + public ResponseEntity getForEntity(URI url, Class responseType) + throws RestClientException { + return this.restTemplate.getForEntity(url, responseType); + } + + @Override + public HttpHeaders headForHeaders(String url, Object... uriVariables) + throws RestClientException { + return this.restTemplate.headForHeaders(url, uriVariables); + } + + @Override + public HttpHeaders headForHeaders(String url, Map uriVariables) + throws RestClientException { + return this.restTemplate.headForHeaders(url, uriVariables); + } + + @Override + public HttpHeaders headForHeaders(URI url) throws RestClientException { + return this.restTemplate.headForHeaders(url); + } + + @Override + public URI postForLocation(String url, Object request, Object... uriVariables) + throws RestClientException { + return this.restTemplate.postForLocation(url, request, uriVariables); + } + + @Override + public URI postForLocation(String url, Object request, Map uriVariables) + throws RestClientException { + return this.restTemplate.postForLocation(url, request, uriVariables); + } + + @Override + public URI postForLocation(URI url, Object request) throws RestClientException { + return this.restTemplate.postForLocation(url, request); + } + + @Override + public T postForObject(String url, Object request, Class responseType, + Object... uriVariables) throws RestClientException { + return this.restTemplate.postForObject(url, request,responseType, uriVariables); + } + + @Override + public T postForObject(String url, Object request, Class responseType, + Map uriVariables) throws RestClientException { + return this.restTemplate.postForObject(url, request,responseType, uriVariables); + } + + @Override + public T postForObject(URI url, Object request, Class responseType) + throws RestClientException { + return this.restTemplate.postForObject(url, request, responseType); + } + + @Override + public ResponseEntity postForEntity(String url, Object request, Class responseType, + Object... uriVariables) throws RestClientException { + return this.restTemplate.postForEntity(url, request, responseType, uriVariables); + } + + @Override + public ResponseEntity postForEntity(String url, Object request, Class responseType, + Map uriVariables) throws RestClientException { + return this.restTemplate.postForEntity(url, request, responseType, uriVariables); + } + + @Override + public ResponseEntity postForEntity(URI url, Object request, Class responseType) + throws RestClientException { + return this.restTemplate.postForEntity(url, request, responseType); + } + + @Override + public void put(String url, Object request, Object... uriVariables) throws RestClientException { + this.restTemplate.put(url, request, uriVariables); + } + + @Override + public void put(String url, Object request, Map uriVariables) + throws RestClientException { + this.restTemplate.put(url, request); + } + + @Override + public void put(URI url, Object request) throws RestClientException { + this.restTemplate.put(url, request); + } + + @Override + public T patchForObject(String url, Object request, Class responseType, + Object... uriVariables) throws RestClientException { + return this.restTemplate.patchForObject(url, request, responseType, uriVariables); + } + + @Override + public T patchForObject(String url, Object request, Class responseType, + Map uriVariables) throws RestClientException { + return this.restTemplate.patchForObject(url, request, responseType, uriVariables); + } + + @Override + public T patchForObject(URI url, Object request, Class responseType) + throws RestClientException { + return this.restTemplate.patchForObject(url, request, responseType); + } + + @Override + public void delete(String url, Object... uriVariables) throws RestClientException { + this.restTemplate.delete(url, uriVariables); + } + + @Override + public void delete(String url, Map uriVariables) throws RestClientException { + this.restTemplate.delete(url, uriVariables); + } + + @Override + public void delete(URI url) throws RestClientException { + this.restTemplate.delete(url); + } + + @Override + public Set optionsForAllow(String url, Object... uriVariables) + throws RestClientException { + return this.restTemplate.optionsForAllow(url, uriVariables); + } + + @Override + public Set optionsForAllow(String url, Map uriVariables) + throws RestClientException { + return this.restTemplate.optionsForAllow(url, uriVariables); + } + + @Override + public Set optionsForAllow(URI url) throws RestClientException { + return this.restTemplate.optionsForAllow(url); + } + + @Override + public ResponseEntity exchange(String url, HttpMethod method, + HttpEntity requestEntity, Class responseType, Object... uriVariables) + throws RestClientException { + return this.restTemplate.exchange(url, method, requestEntity, responseType, uriVariables); + } + + @Override + public ResponseEntity exchange(String url, HttpMethod method, + HttpEntity requestEntity, Class responseType, Map uriVariables) + throws RestClientException { + return this.restTemplate.exchange(url, method, requestEntity, responseType, uriVariables); + } + + @Override + public ResponseEntity exchange(URI url, HttpMethod method, HttpEntity requestEntity, + Class responseType) throws RestClientException { + return this.restTemplate.exchange(url, method, requestEntity, responseType); + } + + @Override + public ResponseEntity exchange(String url, HttpMethod method, + HttpEntity requestEntity, ParameterizedTypeReference responseType, + Object... uriVariables) throws RestClientException { + return this.restTemplate.exchange(url, method, requestEntity, responseType, uriVariables); + } + + @Override + public ResponseEntity exchange(String url, HttpMethod method, + HttpEntity requestEntity, ParameterizedTypeReference responseType, + Map uriVariables) throws RestClientException { + return this.restTemplate.exchange(url, method, requestEntity, responseType, uriVariables); + } + + @Override + public ResponseEntity exchange(URI url, HttpMethod method, HttpEntity requestEntity, + ParameterizedTypeReference responseType) throws RestClientException { + return this.restTemplate.exchange(url, method, requestEntity, responseType); + } + + @Override + public ResponseEntity exchange(RequestEntity requestEntity, Class responseType) + throws RestClientException { + return this.restTemplate.exchange(requestEntity, responseType); + } + + @Override + public ResponseEntity exchange(RequestEntity requestEntity, + ParameterizedTypeReference responseType) throws RestClientException { + return this.restTemplate.exchange(requestEntity, responseType); + } + + @Override + public T execute(String url, HttpMethod method, RequestCallback requestCallback, + ResponseExtractor responseExtractor, Object... uriVariables) throws RestClientException { + return this.restTemplate.execute(url, method, requestCallback, responseExtractor, uriVariables); + } + + @Override + public T execute(String url, HttpMethod method, RequestCallback requestCallback, + ResponseExtractor responseExtractor, Map uriVariables) + throws RestClientException { + return this.restTemplate.execute(url, method, requestCallback, responseExtractor, uriVariables); + } + + @Override + public T execute(URI url, HttpMethod method, RequestCallback requestCallback, + ResponseExtractor responseExtractor) throws RestClientException { + return this.restTemplate.execute(url, method, requestCallback, responseExtractor); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/config/JpaConfiguration.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/config/JpaConfiguration.java new file mode 100644 index 000000000..5701d3a74 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/config/JpaConfiguration.java @@ -0,0 +1,9 @@ +package com.woowacourse.pickgit.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaConfiguration { +} 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 new file mode 100644 index 000000000..dadcd7e22 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/config/OAuthConfiguration.java @@ -0,0 +1,71 @@ +package com.woowacourse.pickgit.config; + +import com.woowacourse.pickgit.authentication.application.OAuthService; +import com.woowacourse.pickgit.authentication.presentation.AuthenticationPrincipalArgumentResolver; +import com.woowacourse.pickgit.authentication.presentation.interceptor.AuthenticationInterceptor; +import com.woowacourse.pickgit.authentication.presentation.interceptor.IgnoreAuthenticationInterceptor; +import com.woowacourse.pickgit.authentication.presentation.interceptor.PathMatchInterceptor; +import java.util.List; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class OAuthConfiguration implements WebMvcConfigurer { + + private final OAuthService oAuthService; + + public OAuthConfiguration(OAuthService oAuthService) { + this.oAuthService = oAuthService; + } + + @Bean + public AuthenticationInterceptor authenticationInterceptor() { + return new AuthenticationInterceptor(oAuthService); + } + + @Bean + public IgnoreAuthenticationInterceptor ignoreAuthenticationInterceptor() { + return new IgnoreAuthenticationInterceptor(oAuthService); + } + + @Bean + public AuthenticationPrincipalArgumentResolver authenticationPrincipalArgumentResolver() { + return new AuthenticationPrincipalArgumentResolver(oAuthService); + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(authenticationPrincipalArgumentResolver()); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + 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/*/comments", HttpMethod.POST) + .addPathPatterns("/api/profiles/me", HttpMethod.GET) + .addPathPatterns("/api/profiles/*/followings", HttpMethod.POST, HttpMethod.DELETE); + + HandlerInterceptor ignoreAuthenticationInterceptor = new PathMatchInterceptor(ignoreAuthenticationInterceptor()) + .addPathPatterns("/api/profiles/*", HttpMethod.GET) + .addPathPatterns("/api/posts/*", HttpMethod.GET) + .excludePatterns("/api/profiles/*/followings", HttpMethod.POST, HttpMethod.DELETE) + .excludePatterns("/api/profiles/me", HttpMethod.GET) + .excludePatterns("/api/posts/me", HttpMethod.GET); + + registry.addInterceptor(authenticationInterceptor) + .addPathPatterns("/**"); + + registry.addInterceptor(ignoreAuthenticationInterceptor) + .addPathPatterns("/**"); + } +} 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 new file mode 100644 index 000000000..12b0f6a83 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/config/PostTestConfiguration.java @@ -0,0 +1,21 @@ +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/ApplicationException.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/ApplicationException.java new file mode 100644 index 000000000..32c74d814 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/ApplicationException.java @@ -0,0 +1,23 @@ +package com.woowacourse.pickgit.exception; + +import org.springframework.http.HttpStatus; + +public abstract class ApplicationException extends RuntimeException { + + private String errorCode; + private HttpStatus httpStatus; + + public ApplicationException(String errorCode, HttpStatus httpStatus, String message) { + super(message); + this.errorCode = errorCode; + this.httpStatus = httpStatus; + } + + public String getErrorCode() { + return errorCode; + } + + public HttpStatus getHttpStatus() { + return httpStatus; + } +} 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 new file mode 100644 index 000000000..f0e766719 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/GlobalExceptionHandler.java @@ -0,0 +1,33 @@ +package com.woowacourse.pickgit.exception; + +import static java.util.Objects.requireNonNull; + +import com.woowacourse.pickgit.exception.dto.ApiErrorResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity methodArgumentNotValidException( + MethodArgumentNotValidException e) { + ApiErrorResponse exceptionResponse = + new ApiErrorResponse(requireNonNull(e.getFieldError()).getDefaultMessage()); + + return ResponseEntity + .status(HttpStatus.BAD_REQUEST.value()) + .body(exceptionResponse); + } + + @ExceptionHandler(ApplicationException.class) + public ResponseEntity authenticationException( + ApplicationException e) { + return ResponseEntity + .status(e.getHttpStatus().value()) + .body(new ApiErrorResponse(e.getErrorCode())); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/authentication/AuthenticationException.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/authentication/AuthenticationException.java new file mode 100644 index 000000000..660fdea7c --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/authentication/AuthenticationException.java @@ -0,0 +1,11 @@ +package com.woowacourse.pickgit.exception.authentication; + +import com.woowacourse.pickgit.exception.ApplicationException; +import org.springframework.http.HttpStatus; + +public abstract class AuthenticationException extends ApplicationException { + + public AuthenticationException(String errorCode, HttpStatus httpStatus, String message) { + super(errorCode, httpStatus, message); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/authentication/InvalidTokenException.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/authentication/InvalidTokenException.java new file mode 100644 index 000000000..eb24ab1c0 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/authentication/InvalidTokenException.java @@ -0,0 +1,13 @@ +package com.woowacourse.pickgit.exception.authentication; + +import org.springframework.http.HttpStatus; + +public class InvalidTokenException extends AuthenticationException { + + private static final String CODE = "A0001"; + private static final String MESSAGE = "토큰 인증 에러"; + + public InvalidTokenException() { + super(CODE, HttpStatus.UNAUTHORIZED, MESSAGE); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/authentication/UnauthorizedException.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/authentication/UnauthorizedException.java new file mode 100644 index 000000000..32fcd59cc --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/authentication/UnauthorizedException.java @@ -0,0 +1,13 @@ +package com.woowacourse.pickgit.exception.authentication; + +import org.springframework.http.HttpStatus; + +public class UnauthorizedException extends AuthenticationException { + + private static final String CODE = "A0002"; + private static final String MESSAGE = "권한 에러"; + + public UnauthorizedException() { + super(CODE, HttpStatus.UNAUTHORIZED, MESSAGE); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/dto/ApiErrorResponse.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/dto/ApiErrorResponse.java new file mode 100644 index 000000000..ee57e58f9 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/dto/ApiErrorResponse.java @@ -0,0 +1,17 @@ +package com.woowacourse.pickgit.exception.dto; + +public class ApiErrorResponse { + + private String errorCode; + + private ApiErrorResponse() { + } + + public ApiErrorResponse(String errorCode) { + this.errorCode = errorCode; + } + + public String getErrorCode() { + return errorCode; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/format/FormatException.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/format/FormatException.java new file mode 100644 index 000000000..80c951a24 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/format/FormatException.java @@ -0,0 +1,11 @@ +package com.woowacourse.pickgit.exception.format; + +import com.woowacourse.pickgit.exception.ApplicationException; +import org.springframework.http.HttpStatus; + +public abstract class FormatException extends ApplicationException { + + public FormatException(String errorCode, HttpStatus httpStatus, String message) { + super(errorCode, httpStatus, message); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/platform/PlatformException.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/platform/PlatformException.java new file mode 100644 index 000000000..47d537061 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/platform/PlatformException.java @@ -0,0 +1,11 @@ +package com.woowacourse.pickgit.exception.platform; + +import com.woowacourse.pickgit.exception.ApplicationException; +import org.springframework.http.HttpStatus; + +public abstract class PlatformException extends ApplicationException { + + public PlatformException(String errorCode, HttpStatus httpStatus, String message) { + super(errorCode, httpStatus, message); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/platform/PlatformHttpErrorException.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/platform/PlatformHttpErrorException.java new file mode 100644 index 000000000..4428c1a6a --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/platform/PlatformHttpErrorException.java @@ -0,0 +1,17 @@ +package com.woowacourse.pickgit.exception.platform; + +import org.springframework.http.HttpStatus; + +public class PlatformHttpErrorException extends PlatformException { + + private static final String CODE = "V0001"; + private static final String MESSAGE = "외부 플랫폼 연동에 실패"; + + public PlatformHttpErrorException() { + super(CODE, HttpStatus.INTERNAL_SERVER_ERROR, MESSAGE); + } + + public PlatformHttpErrorException(String message) { + super(CODE, HttpStatus.INTERNAL_SERVER_ERROR, message); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/CannotAddTagException.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/CannotAddTagException.java new file mode 100644 index 000000000..39a277654 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/CannotAddTagException.java @@ -0,0 +1,13 @@ +package com.woowacourse.pickgit.exception.post; + +import org.springframework.http.HttpStatus; + +public class CannotAddTagException extends PostException { + + private static final String CODE = "P0001"; + private static final String MESSAGE = "태그 추가 에러"; + + public CannotAddTagException() { + super(CODE, HttpStatus.BAD_REQUEST, MESSAGE); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/CommentFormatException.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/CommentFormatException.java new file mode 100644 index 000000000..11560a0d0 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/CommentFormatException.java @@ -0,0 +1,13 @@ +package com.woowacourse.pickgit.exception.post; + +import org.springframework.http.HttpStatus; + +public class CommentFormatException extends PostException { + + private static final String CODE = "F0002"; + private static final String MESSAGE = "댓글 길이 에러"; + + public CommentFormatException() { + super(CODE, HttpStatus.BAD_REQUEST, MESSAGE); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/PostException.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/PostException.java new file mode 100644 index 000000000..26f2a56a6 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/PostException.java @@ -0,0 +1,11 @@ +package com.woowacourse.pickgit.exception.post; + +import com.woowacourse.pickgit.exception.ApplicationException; +import org.springframework.http.HttpStatus; + +public abstract class PostException extends ApplicationException { + + public PostException(String errorCode, HttpStatus httpStatus, String message) { + super(errorCode, httpStatus, 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 new file mode 100644 index 000000000..bb5feb792 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/PostFormatException.java @@ -0,0 +1,13 @@ +package com.woowacourse.pickgit.exception.post; + +import org.springframework.http.HttpStatus; + +public class PostFormatException extends PostException { + + private static final String CODE = "F0001"; + 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/PostNotFoundException.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/PostNotFoundException.java new file mode 100644 index 000000000..d20c581ea --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/PostNotFoundException.java @@ -0,0 +1,11 @@ +package com.woowacourse.pickgit.exception.post; + +import org.springframework.http.HttpStatus; + +public class PostNotFoundException extends PostException { + + 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/TagFormatException.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/TagFormatException.java new file mode 100644 index 000000000..208b63cb0 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/TagFormatException.java @@ -0,0 +1,13 @@ +package com.woowacourse.pickgit.exception.post; + +import org.springframework.http.HttpStatus; + +public class TagFormatException extends PostException { + + private static final String CODE = "F0003"; + private static final String MESSAGE = "태그 포맷 에러"; + + public TagFormatException() { + super(CODE, HttpStatus.BAD_REQUEST,MESSAGE); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/user/DuplicateFollowException.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/user/DuplicateFollowException.java new file mode 100644 index 000000000..be244f56c --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/user/DuplicateFollowException.java @@ -0,0 +1,13 @@ +package com.woowacourse.pickgit.exception.user; + +import org.springframework.http.HttpStatus; + +public class DuplicateFollowException extends UserException { + + private static final String CODE = "U0002"; + private static final String MESSAGE = "이미 팔로우 중 입니다."; + + public DuplicateFollowException() { + super(CODE, HttpStatus.BAD_REQUEST, MESSAGE); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/user/InvalidFollowException.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/user/InvalidFollowException.java new file mode 100644 index 000000000..a6847fa56 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/user/InvalidFollowException.java @@ -0,0 +1,13 @@ +package com.woowacourse.pickgit.exception.user; + +import org.springframework.http.HttpStatus; + +public class InvalidFollowException extends UserException { + + private static final String CODE = "U0003"; + private static final String MESSAGE = "존재하지 않는 팔로우 입니다."; + + public InvalidFollowException() { + super(CODE, HttpStatus.BAD_REQUEST, MESSAGE); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/user/InvalidUserException.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/user/InvalidUserException.java new file mode 100644 index 000000000..c44a8c5fe --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/user/InvalidUserException.java @@ -0,0 +1,13 @@ +package com.woowacourse.pickgit.exception.user; + +import org.springframework.http.HttpStatus; + +public class InvalidUserException extends UserException { + + private static final String CODE = "U0001"; + private static final String MESSAGE = "유효하지 않은 유저입니다."; + + public InvalidUserException() { + super(CODE, HttpStatus.BAD_REQUEST, MESSAGE); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/user/SameSourceTargetUserException.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/user/SameSourceTargetUserException.java new file mode 100644 index 000000000..e02b7f370 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/user/SameSourceTargetUserException.java @@ -0,0 +1,13 @@ +package com.woowacourse.pickgit.exception.user; + +import org.springframework.http.HttpStatus; + +public class SameSourceTargetUserException extends UserException { + + private static final String CODE = "U0004"; + private static final String MESSAGE = "같은 Source 와 Target 유저입니다."; + + public SameSourceTargetUserException() { + super(CODE, HttpStatus.BAD_REQUEST, MESSAGE); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/user/UserException.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/user/UserException.java new file mode 100644 index 000000000..08791ef32 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/user/UserException.java @@ -0,0 +1,11 @@ +package com.woowacourse.pickgit.exception.user; + +import com.woowacourse.pickgit.exception.ApplicationException; +import org.springframework.http.HttpStatus; + +public abstract class UserException extends ApplicationException { + + public UserException(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 new file mode 100644 index 000000000..6d95a7257 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/user/UserNotFoundException.java @@ -0,0 +1,11 @@ +package com.woowacourse.pickgit.exception.user; + +import org.springframework.http.HttpStatus; + +public class UserNotFoundException extends UserException { + + 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 new file mode 100644 index 000000000..ce58a11f8 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/PostDtoAssembler.java @@ -0,0 +1,46 @@ +package com.woowacourse.pickgit.post.application; + +import com.woowacourse.pickgit.authentication.domain.user.AppUser; +import com.woowacourse.pickgit.post.application.dto.CommentResponse; +import com.woowacourse.pickgit.post.application.dto.response.PostResponseDto; +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 java.util.stream.Collectors; + +public class PostDtoAssembler { + + private PostDtoAssembler() { + } + + public static List assembleFrom(AppUser appUser, List posts) { + return posts.stream() + .map(post -> convertFrom(post, appUser)) + .collect(Collectors.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() + .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); + } + + 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())); + } +} 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 new file mode 100644 index 000000000..46c6beb17 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/PostService.java @@ -0,0 +1,185 @@ +package com.woowacourse.pickgit.post.application; + +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.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.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.PlatformRepositoryExtractor; +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.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; + +@Service +@Transactional +public class PostService { + + 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, + PostRepository postRepository, + PickGitStorage pickgitStorage, + PlatformRepositoryExtractor platformRepositoryExtractor, + TagService tagService, EntityManager entityManager) { + 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 = new Post(postContent, getImages(postRequestDto), + postRequestDto.getGithubRepoUrl(), user); + + List tags = tagService.findOrCreateTags(new TagsDto(postRequestDto.getTags())); + post.addTags(tags); + + Post findPost = postRepository.save(post); + return new PostImageUrlResponseDto(findPost.getId(), findPost.getImageUrls()); + } + + private User findUserByName(String username) { + return userRepository + .findByBasicProfile_Name(username) + .orElseThrow(() -> new UserNotFoundException( + "U0001", + HttpStatus.INTERNAL_SERVER_ERROR, + "해당하는 사용자를 찾을 수 없습니다.")); + } + + private Images getImages(PostRequestDto postRequestDto) { + List files = filesOf(postRequestDto); + + return new Images(getImages(postRequestDto, files)); + } + + private List getImages(PostRequestDto postRequestDto, List files) { + return pickgitStorage + .store(files, postRequestDto.getUsername()) + .stream() + .map(Image::new) + .collect(toList()); + } + + private List filesOf(PostRequestDto postRequestDto) { + return postRequestDto.getImages().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 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); + } + + @Transactional(readOnly = true) + public RepositoriesResponseDto showRepositories(RepositoryRequestDto repositoryRequestDto) { + List repositories = platformRepositoryExtractor + .extract(repositoryRequestDto.getToken(), repositoryRequestDto.getUsername()); + + return new RepositoriesResponseDto(repositories); + } + + @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); + } + + @Transactional(readOnly = true) + public List readMyFeed(HomeFeedRequest homeFeedRequest) { + return readFeed(homeFeedRequest, homeFeedRequest.getAppUser().getUsername()); + } + + @Transactional(readOnly = true) + public List readUserFeed(HomeFeedRequest homeFeedRequest, String username) { + return readFeed(homeFeedRequest, username); + } + + 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); + } +} 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 new file mode 100644 index 000000000..2b62e6053 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/CommentResponse.java @@ -0,0 +1,42 @@ +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/application/dto/request/PostRequestDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/request/PostRequestDto.java new file mode 100644 index 000000000..bc1c40f7d --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/request/PostRequestDto.java @@ -0,0 +1,51 @@ +package com.woowacourse.pickgit.post.application.dto.request; + +import java.util.List; +import org.springframework.web.multipart.MultipartFile; + +public class PostRequestDto { + + private String token; + private String username; + private List images; + private String githubRepoUrl; + private List tags; + private String content; + + private PostRequestDto() { + } + + public PostRequestDto(String token, String username, List images, + String githubRepoUrl, List tags, String content) { + this.token = token; + this.username = username; + this.images = images; + this.githubRepoUrl = githubRepoUrl; + this.tags = tags; + this.content = content; + } + + public String getToken() { + return token; + } + + public String getUsername() { + return username; + } + + public List getImages() { + return images; + } + + public String getGithubRepoUrl() { + return githubRepoUrl; + } + + 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/request/RepositoryRequestDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/request/RepositoryRequestDto.java new file mode 100644 index 000000000..310068212 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/request/RepositoryRequestDto.java @@ -0,0 +1,23 @@ +package com.woowacourse.pickgit.post.application.dto.request; + +public class RepositoryRequestDto { + + private String token; + private String username; + + private RepositoryRequestDto() { + } + + public RepositoryRequestDto(String token, String username) { + this.token = token; + this.username = username; + } + + public String getToken() { + return token; + } + + public String getUsername() { + return username; + } +} 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 new file mode 100644 index 000000000..0f6ec22b0 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/response/PostImageUrlResponseDto.java @@ -0,0 +1,29 @@ +package com.woowacourse.pickgit.post.application.dto.response; + +import java.util.List; + +public class PostImageUrlResponseDto { + + private Long id; + private List imageUrls; + + private PostImageUrlResponseDto() { + } + + public PostImageUrlResponseDto(Long id) { + this(id, null); + } + + public PostImageUrlResponseDto(Long id, List imageUrls) { + this.id = id; + this.imageUrls = imageUrls; + } + + public Long getId() { + return id; + } + + public List getImageUrls() { + return 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 new file mode 100644 index 000000000..fa3bcfec6 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/response/PostResponseDto.java @@ -0,0 +1,90 @@ +package com.woowacourse.pickgit.post.application.dto.response; + +import com.woowacourse.pickgit.post.application.dto.CommentResponse; +import java.time.LocalDateTime; +import java.util.List; + +public class PostResponseDto { + + 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 isLiked; + + 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) { + 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.isLiked = isLiked; + } + + 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 getIsLiked() { + return isLiked; + } +} 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 new file mode 100644 index 000000000..fff0228d9 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/response/RepositoriesResponseDto.java @@ -0,0 +1,21 @@ +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/PlatformRepositoryExtractor.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/PlatformRepositoryExtractor.java new file mode 100644 index 000000000..6c0b7302a --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/PlatformRepositoryExtractor.java @@ -0,0 +1,9 @@ +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 new file mode 100644 index 000000000..c21f700ad --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/Post.java @@ -0,0 +1,172 @@ +package com.woowacourse.pickgit.post.domain; + +import static java.util.stream.Collectors.toList; + +import com.woowacourse.pickgit.exception.post.CannotAddTagException; +import com.woowacourse.pickgit.post.domain.comment.Comment; +import com.woowacourse.pickgit.post.domain.comment.Comments; +import com.woowacourse.pickgit.post.domain.content.Images; +import com.woowacourse.pickgit.post.domain.like.Likes; +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; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +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; + +@Entity +@EntityListeners(AuditingEntityListener.class) +public class Post { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Embedded + private Images images; + + @Embedded + private PostContent content; + + private String githubRepoUrl; + + @Embedded + private Likes likes; + + @Embedded + private Comments comments; + + @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) { + this.id = id; + this.images = images; + this.content = content; + this.githubRepoUrl = githubRepoUrl; + this.likes = likes; + this.comments = comments; + this.postTags = postTags; + this.user = user; + } + + 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 void addComment(Comment comment) { + comments.addComment(comment); + } + + public Long getId() { + return id; + } + + public List getImageUrls() { + return images.getUrls(); + } + + 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 List getTags() { + return postTags.stream() + .map(PostTag::getTag) + .collect(toList()); + } + + @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); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + public List getImagaeUrls() { + return images.getImageUrls(); + } + + public String getGithubRepoUrl() { + return githubRepoUrl; + } + + public int getLikeCounts() { + return likes.getCounts(); + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public String getContent() { + return content.getContent(); + } + + public User getUser() { + return user; + } + + public List getComments() { + return comments.getComments(); + } + + public boolean isLikedBy(String userName) { + return likes.contains(userName); + } +} 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 new file mode 100644 index 000000000..8bf9dad6d --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/PostContent.java @@ -0,0 +1,34 @@ +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/PostRepository.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/PostRepository.java new file mode 100644 index 000000000..b4e0e386e --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/PostRepository.java @@ -0,0 +1,21 @@ +package com.woowacourse.pickgit.post.domain; + +import com.woowacourse.pickgit.user.domain.User; +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 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") + 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/PostTag.java new file mode 100644 index 000000000..dc61840c0 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/PostTag.java @@ -0,0 +1,56 @@ +package com.woowacourse.pickgit.post.domain; + +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; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; + +@Entity +public class PostTag { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + 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") + private Tag tag; + + protected PostTag() { + } + + public PostTag(Post post, Tag tag) { + this.post = post; + this.tag = tag; + } + + public Tag getTag() { + return tag; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + PostTag postTag = (PostTag) o; + return Objects.equals(id, postTag.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } +} 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 new file mode 100644 index 000000000..679356e2e --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/Posts.java @@ -0,0 +1,21 @@ +package com.woowacourse.pickgit.post.domain; + +import java.util.ArrayList; +import java.util.List; +import javax.persistence.Embeddable; +import javax.persistence.FetchType; +import javax.persistence.OneToMany; + +@Embeddable +public class Posts { + + @OneToMany(mappedBy = "user", fetch = FetchType.LAZY) + private List posts = new ArrayList<>(); + + public Posts() { + } + + public int getCounts() { + return posts.size(); + } +} 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 new file mode 100644 index 000000000..10c164b82 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/comment/Comment.java @@ -0,0 +1,81 @@ +package com.woowacourse.pickgit.post.domain.comment; + +import com.woowacourse.pickgit.post.domain.Post; +import com.woowacourse.pickgit.user.domain.User; +import java.util.Objects; +import javax.persistence.Embedded; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; + +@Entity +public class Comment { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Embedded + private CommentContent content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + private Post post; + + protected Comment() { + } + + public Comment(String content) { + this.content = new CommentContent(content); + } + + public Comment writeBy(User user) { + this.user = user; + return this; + } + + public Comment toPost(Post post) { + this.post = post; + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Comment comment = (Comment) o; + return Objects.equals(id, comment.id); + } + + @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 new file mode 100644 index 000000000..0db75e351 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/comment/CommentContent.java @@ -0,0 +1,37 @@ +package com.woowacourse.pickgit.post.domain.comment; + +import com.woowacourse.pickgit.exception.post.CommentFormatException; +import java.util.Objects; +import javax.persistence.Column; +import javax.persistence.Embeddable; +import javax.persistence.Lob; + +@Embeddable +public class CommentContent { + + private static final int MAX_COMMENT_CONTENT_LENGTH = 100; + + @Column(nullable = false, length = 100) + @Lob + private String content; + + protected CommentContent() { + } + + public CommentContent(String content) { + if (isNotValidContent(content)) { + throw new CommentFormatException(); + } + this.content = content; + } + + private boolean isNotValidContent(String content) { + return Objects.isNull(content) + || content.isEmpty() + || content.length() > MAX_COMMENT_CONTENT_LENGTH; + } + + public String getContent() { + return 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 new file mode 100644 index 000000000..2768861f5 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/comment/Comments.java @@ -0,0 +1,26 @@ +package com.woowacourse.pickgit.post.domain.comment; + +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 Comments { + + @OneToMany(mappedBy = "post", fetch = FetchType.LAZY, cascade = CascadeType.PERSIST) + private List comments = new ArrayList<>(); + + public Comments() { + } + + public void addComment(Comment comment) { + comments.add(comment); + } + + public List getComments() { + return comments; + } +} 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 new file mode 100644 index 000000000..6ce856e1a --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/content/Image.java @@ -0,0 +1,59 @@ +package com.woowacourse.pickgit.post.domain.content; + +import com.woowacourse.pickgit.post.domain.Post; +import java.util.Objects; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; + +@Entity +public class Image { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String url; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + private Post post; + + protected Image() { + } + + public Image(String url) { + this.url = url; + } + + public String getUrl() { + return url; + } + + public Image toPost(Post post) { + this.post = post; + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Image image = (Image) o; + return Objects.equals(id, image.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } +} 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 new file mode 100644 index 000000000..3d34b7150 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/content/Images.java @@ -0,0 +1,41 @@ +package com.woowacourse.pickgit.post.domain.content; + +import static java.util.stream.Collectors.toList; + +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; +import javax.persistence.OneToMany; + +@Embeddable +public class Images { + + @OneToMany(mappedBy = "post", fetch = FetchType.LAZY, cascade = CascadeType.PERSIST) + private List images = new ArrayList<>(); + + protected Images() { + } + + public Images(List images) { + this.images = images; + } + + public List getUrls() { + return images.stream() + .map(Image::getUrl) + .collect(toList()); + } + public List getImageUrls() { + return images.stream() + .map(Image::getUrl) + .collect(Collectors.toList()); + } + + public void setMapping(Post post) { + images.forEach(image -> image.toPost(post)); + } +} 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/domain/dto/RepositoryResponseDto.java new file mode 100644 index 000000000..b3cfb14c0 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/dto/RepositoryResponseDto.java @@ -0,0 +1,27 @@ +package com.woowacourse.pickgit.post.domain.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class RepositoryResponseDto { + + private String name; + + @JsonProperty("html_url") + private String url; + + private RepositoryResponseDto() { + } + + public RepositoryResponseDto(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/domain/like/Like.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/like/Like.java new file mode 100644 index 000000000..42b9f00fa --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/like/Like.java @@ -0,0 +1,53 @@ +package com.woowacourse.pickgit.post.domain.like; + +import com.woowacourse.pickgit.post.domain.Post; +import com.woowacourse.pickgit.user.domain.User; +import java.util.Objects; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.Table; + +@Entity +@Table(name = "LIKES") +public class Like { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + private Post post; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + protected Like() { + } + + public boolean contains(String userName) { + return user.getName().equals(userName); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Like like = (Like) o; + return Objects.equals(id, like.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } +} 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 new file mode 100644 index 000000000..a37f9f327 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/like/Likes.java @@ -0,0 +1,26 @@ +package com.woowacourse.pickgit.post.domain.like; + +import java.util.ArrayList; +import java.util.List; +import javax.persistence.Embeddable; +import javax.persistence.FetchType; +import javax.persistence.OneToMany; + +@Embeddable +public class Likes { + + @OneToMany(mappedBy = "post", fetch = FetchType.LAZY) + private List likes = new ArrayList<>(); + + protected Likes() { + } + + public int getCounts() { + return likes.size(); + } + + public boolean contains(String userName) { + return likes.stream() + .anyMatch(like -> like.contains(userName)); + } +} 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/GithubRepositoryApiRequester.java new file mode 100644 index 000000000..62d291120 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/infrastructure/GithubRepositoryApiRequester.java @@ -0,0 +1,25 @@ +package com.woowacourse.pickgit.post.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 GithubRepositoryApiRequester implements PlatformRepositoryApiRequester { + + @Override + public String request(String token, String url) { + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.setBearerAuth(token); + + 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/post/infrastructure/GithubRepositoryExtractor.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/infrastructure/GithubRepositoryExtractor.java new file mode 100644 index 000000000..2d25da68e --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/infrastructure/GithubRepositoryExtractor.java @@ -0,0 +1,45 @@ +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.woowacourse.pickgit.post.domain.PlatformRepositoryExtractor; +import com.woowacourse.pickgit.post.domain.dto.RepositoryResponseDto; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class GithubRepositoryExtractor implements PlatformRepositoryExtractor { + + private static final String API_URL_FORMAT = "https://api.github.com/users/%s/repos"; + + private final ObjectMapper objectMapper; + private final PlatformRepositoryApiRequester platformRepositoryApiRequester; + + public GithubRepositoryExtractor( + ObjectMapper objectMapper, + PlatformRepositoryApiRequester platformRepositoryApiRequester) { + this.objectMapper = objectMapper; + this.platformRepositoryApiRequester = platformRepositoryApiRequester; + } + + @Override + public List extract(String token, String username) { + String apiUrl = generateApiUrl(username); + String response = platformRepositoryApiRequester.request(token, apiUrl); + + return parseToRepositories(response); + } + + private String generateApiUrl(String username) { + return String.format(API_URL_FORMAT, username); + } + + private List parseToRepositories(String response) { + try { + return objectMapper.readValue(response, new TypeReference<>() {}); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException(); + } + } +} 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/infrastructure/PlatformRepositoryApiRequester.java new file mode 100644 index 000000000..a0e2a19b8 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/infrastructure/PlatformRepositoryApiRequester.java @@ -0,0 +1,6 @@ +package com.woowacourse.pickgit.post.infrastructure; + +public interface PlatformRepositoryApiRequester { + + String request(String token, String url); +} 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/infrastructure/RestClient.java new file mode 100644 index 000000000..db128b171 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/infrastructure/RestClient.java @@ -0,0 +1,6 @@ +package com.woowacourse.pickgit.post.infrastructure; + +import org.springframework.web.client.RestOperations; + +public interface RestClient extends RestOperations { +} 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 new file mode 100644 index 000000000..418195b57 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/infrastructure/S3Storage.java @@ -0,0 +1,80 @@ +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 com.woowacourse.pickgit.exception.platform.PlatformHttpErrorException; +import com.woowacourse.pickgit.post.presentation.PickGitStorage; +import java.io.File; +import java.util.List; +import java.util.Objects; +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; + +@Repository +@Profile("!test") +public class S3Storage implements PickGitStorage { + + private static final String MULTIPART_KEY = "files"; + + private final RestClient restClient; + + @Value("${storage.pickgit.s3proxy}") + private String s3ProxyUrl; + + public S3Storage(RestClient restClient) { + this.restClient = restClient; + } + + @Override + public List store(List files, String userName) { + StorageDto response = restClient + .postForEntity(s3ProxyUrl, createBody(files, userName), StorageDto.class) + .getBody(); + + return response.getUrls(); + } + + private MultiValueMap createBody( + List files, + String userName + ) { + MultiValueMap body = createMultipartMap(files); + body.add("userName", userName); + return body; + } + + private MultiValueMap createMultipartMap(List files) { + MultiValueMap body = new LinkedMultiValueMap<>(); + + files.forEach(file -> body.add(MULTIPART_KEY, new FileSystemResource(file))); + + return body; + } + + public static class StorageDto { + + private List urls; + + private StorageDto() { + } + + public StorageDto(List urls) { + this.urls = urls; + } + + public List getUrls() { + return urls; + } + } +} 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 new file mode 100644 index 000000000..45c28a7c7 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/PickGitStorage.java @@ -0,0 +1,9 @@ +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 new file mode 100644 index 000000000..f0502e443 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/PostController.java @@ -0,0 +1,134 @@ +package com.woowacourse.pickgit.post.presentation; + +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.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 com.woowacourse.pickgit.post.presentation.dto.request.PostRequest; +import java.net.URI; +import java.util.List; +import javax.validation.Valid; +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.PostMapping; +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 = "*") +public class PostController { + + private static final String REDIRECT_URL = "/api/posts/%s/%d"; + + private final PostService postService; + + 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) + ); + + return ResponseEntity + .created(redirectUrl(user, responseDto)) + .build(); + } + + @PostMapping("/posts/{postId}/comments") + public ResponseEntity addComment( + @Authenticated AppUser user, + @PathVariable Long postId, + @Valid @RequestBody ContentRequest request + ) { + validateIsGuest(user); + + CommentRequest commentRequest = + new CommentRequest(user.getUsername(), request.getContent(), postId); + CommentResponse response = postService.addComment(commentRequest); + + return ResponseEntity.ok(response); + } + + private void validateIsGuest(AppUser user) { + if (user.isGuest()) { + throw new UnauthorizedException(); + } + } + + 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(REDIRECT_URL, user.getUsername(), responseDto.getId())); + } + + @GetMapping("/github/{username}/repositories") + public ResponseEntity> showRepositories( + @Authenticated AppUser user, + @PathVariable String username + ) { + String token = user.getAccessToken(); + RepositoriesResponseDto responseDto = postService + .showRepositories(new RepositoryRequestDto(token, username)); + + return ResponseEntity.ok(responseDto.getRepositories()); + } +} 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/presentation/dto/request/CommentRequest.java new file mode 100644 index 000000000..b7a7a73dd --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/request/CommentRequest.java @@ -0,0 +1,29 @@ +package com.woowacourse.pickgit.post.presentation.dto.request; + +public class CommentRequest { + + private String userName; + private String content; + private Long postId; + + private CommentRequest() { + } + + public CommentRequest(String userName, String content, Long postId) { + this.userName = userName; + this.content = content; + this.postId = postId; + } + + public String getUserName() { + return userName; + } + + public String getContent() { + return content; + } + + public Long getPostId() { + return postId; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/request/ContentRequest.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/request/ContentRequest.java new file mode 100644 index 000000000..70a4346fe --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/request/ContentRequest.java @@ -0,0 +1,22 @@ +package com.woowacourse.pickgit.post.presentation.dto.request; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +public class ContentRequest { + + @NotBlank(message = "F0001") + @Size(max = 100, message = "F0002") + private String content; + + private ContentRequest() { + } + + public ContentRequest(String content) { + this.content = content; + } + + public String getContent() { + return content; + } +} 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 new file mode 100644 index 000000000..5537aaf21 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/request/HomeFeedRequest.java @@ -0,0 +1,28 @@ +package com.woowacourse.pickgit.post.presentation.dto.request; + +import com.woowacourse.pickgit.authentication.domain.user.AppUser; + +public class HomeFeedRequest { + + private AppUser appUser; + private Long page; + private Long limit; + + public HomeFeedRequest(AppUser appUser, Long page, Long limit) { + this.appUser = appUser; + this.page = page; + this.limit = limit; + } + + public AppUser getAppUser() { + return appUser; + } + + 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/post/presentation/dto/request/PostRequest.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/request/PostRequest.java new file mode 100644 index 000000000..a7f53c0d1 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/request/PostRequest.java @@ -0,0 +1,49 @@ +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 org.springframework.web.multipart.MultipartFile; + +public class PostRequest { + + private List images; + + private String githubRepoUrl; + + private List tags; + + private String content; + + private PostRequest() { + } + + public PostRequest( + List images, + String githubRepoUrl, + List tags, + String content + ) { + this.images = images; + this.githubRepoUrl = githubRepoUrl; + this.tags = tags; + this.content = content; + } + + public List getImages() { + return images; + } + + public String getGithubRepoUrl() { + return githubRepoUrl; + } + + 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 new file mode 100644 index 000000000..5cb3ee179 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/response/CommentResponse.java @@ -0,0 +1,26 @@ +package com.woowacourse.pickgit.post.presentation.dto.response; + +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); + } +} 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 new file mode 100644 index 000000000..61e7bc441 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/application/ExtractionRequestDto.java @@ -0,0 +1,29 @@ +package com.woowacourse.pickgit.tag.application; + +public class ExtractionRequestDto { + + private String accessToken; + private String userName; + private String repositoryName; + + private ExtractionRequestDto() { + } + + public ExtractionRequestDto(String accessToken, String userName, String repositoryName) { + this.accessToken = accessToken; + this.userName = userName; + this.repositoryName = repositoryName; + } + + public String getAccessToken() { + return accessToken; + } + + public String getUserName() { + return userName; + } + + public String getRepositoryName() { + return repositoryName; + } +} 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 new file mode 100644 index 000000000..e40da65f5 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/application/TagService.java @@ -0,0 +1,44 @@ +package com.woowacourse.pickgit.tag.application; + +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.List; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +public class TagService { + + private final PlatformTagExtractor platformTagExtractor; + private final TagRepository tagRepository; + + public TagService(PlatformTagExtractor platformTagExtractor, + TagRepository tagRepository) { + this.platformTagExtractor = platformTagExtractor; + this.tagRepository = tagRepository; + } + + @Transactional(readOnly = true) + public TagsDto extractTags(ExtractionRequestDto extractionRequestDto) { + String accessToken = extractionRequestDto.getAccessToken(); + String userName = extractionRequestDto.getUserName(); + String repositoryName = extractionRequestDto.getRepositoryName(); + List tags = platformTagExtractor + .extractTags(accessToken, userName, repositoryName); + return new TagsDto(tags); + } + + @Transactional(readOnly = true) + public List findOrCreateTags(TagsDto tagsDto) { + List tagNames = tagsDto.getTags(); + List tags = new ArrayList<>(); + for (String tagName : tagNames) { + tagRepository.findByName(tagName) + .ifPresentOrElse(tags::add, () -> tags.add(new Tag(tagName))); + } + 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 new file mode 100644 index 000000000..6cca60615 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/application/TagsDto.java @@ -0,0 +1,19 @@ +package com.woowacourse.pickgit.tag.application; + +import java.util.List; + +public class TagsDto { + + private List tags; + + private TagsDto() { + } + + public TagsDto(List tags) { + this.tags = tags; + } + + public List getTags() { + return tags; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/domain/PlatformTagExtractor.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/domain/PlatformTagExtractor.java new file mode 100644 index 000000000..3cb61d625 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/domain/PlatformTagExtractor.java @@ -0,0 +1,8 @@ +package com.woowacourse.pickgit.tag.domain; + +import java.util.List; + +public interface PlatformTagExtractor { + + List extractTags(String accessToken, String userName, String repositoryName); +} 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 new file mode 100644 index 000000000..9b0ea626b --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/domain/Tag.java @@ -0,0 +1,66 @@ +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 { + + private static final int MAX_TAG_LENGTH = 20; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 20) + private String name; + + @OneToMany(mappedBy = "tag") + private List postTags = new ArrayList<>(); + + protected Tag() { + } + + public Tag(String name) { + if (isNotValidTag(name)) { + throw new TagFormatException(); + } + this.name = name; + } + + private boolean isNotValidTag(String name) { + return Objects.isNull(name) + || name.isEmpty() + || name.length() > MAX_TAG_LENGTH; + } + + public String getName() { + return name; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Tag tag = (Tag) o; + return Objects.equals(getName(), tag.getName()); + } + + @Override + public int hashCode() { + return Objects.hash(getName()); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/domain/TagRepository.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/domain/TagRepository.java new file mode 100644 index 000000000..a5ecd36ec --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/domain/TagRepository.java @@ -0,0 +1,9 @@ +package com.woowacourse.pickgit.tag.domain; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TagRepository extends JpaRepository { + + Optional findByName(String name); +} 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 new file mode 100644 index 000000000..3b19c36ef --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/infrastructure/GithubApiRequester.java @@ -0,0 +1,21 @@ +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/GithubTagExtractor.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/infrastructure/GithubTagExtractor.java new file mode 100644 index 000000000..b573df75a --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/infrastructure/GithubTagExtractor.java @@ -0,0 +1,49 @@ +package com.woowacourse.pickgit.tag.infrastructure; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.woowacourse.pickgit.tag.domain.PlatformTagExtractor; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Set; +import org.springframework.stereotype.Component; + +@Component +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 ObjectMapper objectMapper; + + public GithubTagExtractor(PlatformApiRequester platformApiRequester, + ObjectMapper objectMapper) { + this.platformApiRequester = platformApiRequester; + this.objectMapper = objectMapper; + } + + public List extractTags(String accessToken, String userName, String repositoryName) { + String url = generateApiUrl(userName, repositoryName); + String response = platformApiRequester.requestTags(url, accessToken); + return parseResponseIntoLanguageTags(response); + } + + private String generateApiUrl(String userName, String repositoryName) { + return String.format(GITHUB_TAG_API_FORMAT, userName, repositoryName); + } + + private List parseResponseIntoLanguageTags(String response) { + try { + Set tags = objectMapper + .readValue(response, new TypeReference>() { + }) + .keySet(); + return new ArrayList<>(tags); + } catch (JsonProcessingException e) { + throw new IllegalStateException(); + } + } +} 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/PlatformApiRequester.java new file mode 100644 index 000000000..4ff44cb8b --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/infrastructure/PlatformApiRequester.java @@ -0,0 +1,6 @@ +package com.woowacourse.pickgit.tag.infrastructure; + +public interface PlatformApiRequester { + + 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 new file mode 100644 index 000000000..2adfb25e8 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/presentation/TagController.java @@ -0,0 +1,36 @@ +package com.woowacourse.pickgit.tag.presentation; + +import com.woowacourse.pickgit.authentication.domain.Authenticated; +import com.woowacourse.pickgit.authentication.domain.user.AppUser; +import com.woowacourse.pickgit.tag.application.ExtractionRequestDto; +import com.woowacourse.pickgit.tag.application.TagService; +import com.woowacourse.pickgit.tag.application.TagsDto; +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.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api") +@CrossOrigin(value = "*") +public class TagController { + + private final TagService tagService; + + public TagController(TagService tagService) { + this.tagService = tagService; + } + + @GetMapping("/github/repositories/{repositoryName}/tags/languages") + public ResponseEntity> extractLanguageTags(@Authenticated AppUser appUser, + @PathVariable String repositoryName) { + String accessToken = appUser.getAccessToken(); + ExtractionRequestDto extractionRequestDto = + new ExtractionRequestDto(accessToken, appUser.getUsername(), repositoryName); + TagsDto tagsDto = tagService.extractTags(extractionRequestDto); + return ResponseEntity.ok(tagsDto.getTags()); + } +} 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 new file mode 100644 index 000000000..2bdbb3c22 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/UserService.java @@ -0,0 +1,92 @@ +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 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 org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +public class UserService { + + private final UserRepository userRepository; + + public UserService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @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 + ); + } + + @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 + ); + } + + User sourceUser = findUserByName(appUser.getUsername()); + + 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 FollowServiceDto followUser(AuthUserServiceDto authUserServiceDto, + String targetUsername) { + User source = findUserByName(authUserServiceDto.getGithubName()); + User target = findUserByName(targetUsername); + + validateDifferentSourceTarget(source, target); + source.follow(target); + + return new FollowServiceDto(target.getFollowerCount(), true); + } + + public FollowServiceDto unfollowUser(AuthUserServiceDto authUserServiceDto, + String targetUsername) { + User source = findUserByName(authUserServiceDto.getGithubName()); + User target = findUserByName(targetUsername); + + validateDifferentSourceTarget(source, target); + source.unfollow(target); + + return new FollowServiceDto(target.getFollowerCount(), false); + } + + private User findUserByName(String githubName) { + return userRepository + .findByBasicProfile_Name(githubName) + .orElseThrow(InvalidUserException::new); + } + + private void validateDifferentSourceTarget(User source, User target) { + if (source.getId() == target.getId()) { + throw new SameSourceTargetUserException(); + } + } +} 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 new file mode 100644 index 000000000..d7940c074 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/AuthUserServiceDto.java @@ -0,0 +1,14 @@ +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 new file mode 100644 index 000000000..3b6a9a6c8 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/FollowServiceDto.java @@ -0,0 +1,20 @@ +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 new file mode 100644 index 000000000..95d3da549 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/UserProfileServiceDto.java @@ -0,0 +1,85 @@ +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/domain/User.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/User.java new file mode 100644 index 000000000..45f04e8b4 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/User.java @@ -0,0 +1,169 @@ +package com.woowacourse.pickgit.user.domain; + +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.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.Objects; +import javax.persistence.Embedded; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; + +@Entity +public class User { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Embedded + private BasicProfile basicProfile; + + @Embedded + private GithubProfile githubProfile; + + @Embedded + private Followers followers = new Followers(); + + @Embedded + private Followings followings = new Followings(); + + @Embedded + private Posts posts = new 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.basicProfile = basicProfile; + this.githubProfile = githubProfile; + } + + public void changeBasicProfile(BasicProfile basicProfile) { + this.basicProfile = basicProfile; + } + + public void changeGithubProfile(GithubProfile githubProfile) { + this.githubProfile = githubProfile; + } + + 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); + } + + public Boolean isFollowing(User targetUser) { + return this.followings.isFollowing(targetUser); + } + + public int getFollowerCount() { + return followers.count(); + } + + public int getFollowingCount() { + return followings.count(); + } + + public int getPostCount() { + return posts.getCounts(); + } + + public Long getId() { + return this.id; + } + + public BasicProfile getBasicProfile() { + return basicProfile; + } + + public GithubProfile getGithubProfile() { + return githubProfile; + } + + public String getName() { + return basicProfile.getName(); + } + + public String getImage() { + return basicProfile.getImage(); + } + + public String getDescription() { + return basicProfile.getDescription(); + } + + public String getGithubUrl() { + return githubProfile.getGithubUrl(); + } + + public String getCompany() { + return githubProfile.getCompany(); + } + + public String getLocation() { + return githubProfile.getLocation(); + } + + public String getWebsite() { + return githubProfile.getWebsite(); + } + + 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) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + User user = (User) o; + return Objects.equals(id, user.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } +} 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 new file mode 100644 index 000000000..ec0a84138 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/UserRepository.java @@ -0,0 +1,10 @@ +package com.woowacourse.pickgit.user.domain; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { + + Optional findByBasicProfile_Name(String name); +} 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 new file mode 100644 index 000000000..7b80b30e9 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/follow/Follow.java @@ -0,0 +1,71 @@ +package com.woowacourse.pickgit.user.domain.follow; + +import com.woowacourse.pickgit.user.domain.User; +import java.util.Objects; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +@Entity +@Table( + uniqueConstraints = { + @UniqueConstraint(columnNames = {"source_id", "target_id"}) + } +) +public class Follow { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "source_id") + private User source; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "target_id") + private User target; + + protected Follow() { + } + + public Follow(User source, User target) { + this.source = source; + this.target = target; + } + + public Long getId() { + return id; + } + + public User getSource() { + return source; + } + + public User getTarget() { + return target; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Follow follow = (Follow) o; + return Objects.equals(source, follow.source) && Objects + .equals(target, follow.target); + } + + @Override + public int hashCode() { + return Objects.hash(source, target); + } +} 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 new file mode 100644 index 000000000..bacceca4c --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/follow/Followers.java @@ -0,0 +1,34 @@ +package com.woowacourse.pickgit.user.domain.follow; + +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 Followers { + + @OneToMany(mappedBy = "target", fetch = FetchType.LAZY, cascade = CascadeType.PERSIST, orphanRemoval = true) + private List followers = new ArrayList<>(); + + public Followers() { + } + + public int count() { + return followers.size(); + } + + public void add(Follow follow) { + followers.add(follow); + } + + public void remove(Follow follow) { + followers.remove(follow); + } + + public List getFollowers() { + return followers; + } +} 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 new file mode 100644 index 000000000..b920ccdf6 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/follow/Followings.java @@ -0,0 +1,42 @@ +package com.woowacourse.pickgit.user.domain.follow; + +import com.woowacourse.pickgit.user.domain.User; +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 Followings { + + @OneToMany(mappedBy = "source", fetch = FetchType.LAZY, cascade = CascadeType.PERSIST, orphanRemoval = true) + private List followings = new ArrayList<>(); + + public Followings() { + } + + public boolean existFollow(Follow follow) { + return this.followings.contains(follow); + } + + public int count() { + return followings.size(); + } + + public void add(Follow follow) { + followings.add(follow); + } + + public void remove(Follow follow) { + followings.remove(follow); + } + + public Boolean isFollowing(User targetUser) { + return followings.stream() + .filter(follow -> follow.getTarget().equals(targetUser)) + .findAny().isPresent(); + } +} + 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 new file mode 100644 index 000000000..a51c606a6 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/profile/BasicProfile.java @@ -0,0 +1,48 @@ +package com.woowacourse.pickgit.user.domain.profile; + +import javax.persistence.Column; +import javax.persistence.Embeddable; + +@Embeddable +public class BasicProfile { + + @Column(nullable = false, updatable = false) + private String name; + + private String image; + + private String description; + + protected BasicProfile() { + } + + public BasicProfile(String name, String image, String description) { + this.name = name; + this.image = image; + this.description = description; + } + + 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; + } + + public void setDescription(String description) { + this.description = description; + } +} 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 new file mode 100644 index 000000000..753a0f977 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/profile/GithubProfile.java @@ -0,0 +1,50 @@ +package com.woowacourse.pickgit.user.domain.profile; + +import javax.persistence.Column; +import javax.persistence.Embeddable; + +@Embeddable +public class GithubProfile { + + @Column(nullable = false, updatable = false) + private String githubUrl; + + private String company; + + private String location; + + private String website; + + private String twitter; + + protected GithubProfile() { + } + + public GithubProfile(String githubUrl, String company, String location, String website, + String twitter) { + this.githubUrl = githubUrl; + this.company = company; + this.location = location; + this.website = website; + this.twitter = twitter; + } + + 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; + } +} 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/domain/statistics/empty.txt new file mode 100644 index 000000000..e69de29bb diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/infrastructure/empty.txt b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/infrastructure/empty.txt new file mode 100644 index 000000000..e69de29bb 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 new file mode 100644 index 000000000..b1327db2b --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/UserController.java @@ -0,0 +1,93 @@ +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.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 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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/profiles") +@CrossOrigin(value = "*") +public class UserController { + + private final UserService userService; + + public UserController(UserService userService) { + this.userService = userService; + } + + @GetMapping("/me") + public ResponseEntity getAuthenticatedUserProfile( + @Authenticated AppUser appUser) { + UserProfileServiceDto userProfileServiceDto = userService.getMyUserProfile( + new AuthUserServiceDto(appUser.getUsername()) + ); + + return ResponseEntity.ok(getUserProfileResponseDto(userProfileServiceDto)); + } + + @GetMapping("/{username}") + public ResponseEntity getUserProfile( + @Authenticated AppUser appUser, + @PathVariable String username) { + UserProfileServiceDto userProfileServiceDto = userService.getUserProfile(appUser, username); + + return ResponseEntity.ok(getUserProfileResponseDto(userProfileServiceDto)); + } + + @PostMapping("/{username}/followings") + public ResponseEntity followUser( + @Authenticated AppUser appUser, + @PathVariable String username + ) { + AuthUserServiceDto authUserServiceDto = + new AuthUserServiceDto(appUser.getUsername()); + + FollowServiceDto followServiceDto = userService.followUser(authUserServiceDto, username); + + return ResponseEntity.ok(createFollowResponseDto(followServiceDto)); + } + + @DeleteMapping("/{username}/followings") + public ResponseEntity unfollowUser( + @Authenticated AppUser appUser, + @PathVariable String username + ) { + AuthUserServiceDto authUserServiceDto = + new AuthUserServiceDto(appUser.getUsername()); + + FollowServiceDto followServiceDto = userService.unfollowUser(authUserServiceDto, username); + + return ResponseEntity.ok(createFollowResponseDto(followServiceDto)); + } + + 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() + ); + } + + private FollowResponse createFollowResponseDto(FollowServiceDto followServiceDto) { + return new FollowResponse( + followServiceDto.getFollowerCount(), + followServiceDto.isFollowing()); + } +} 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 new file mode 100644 index 000000000..581b07081 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/AuthUserRequest.java @@ -0,0 +1,17 @@ +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/FollowResponse.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/FollowResponse.java new file mode 100644 index 000000000..af7b6c429 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/FollowResponse.java @@ -0,0 +1,23 @@ +package com.woowacourse.pickgit.user.presentation.dto; + +public class FollowResponse { + + private int followerCount; + private boolean following; + + private FollowResponse() { + } + + 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/UserProfileResponse.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/UserProfileResponse.java new file mode 100644 index 000000000..f0c8f466c --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/UserProfileResponse.java @@ -0,0 +1,88 @@ +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/resources/application-local.yml b/backend/pick-git/src/main/resources/application-local.yml new file mode 100644 index 000000000..a7e95da4f --- /dev/null +++ b/backend/pick-git/src/main/resources/application-local.yml @@ -0,0 +1,15 @@ +spring: + datasource: + url: jdbc:mariadb://localhost:3306/pickgit?serverTimezone=UTC&characterEncoding=UTF-8 + username: root + password: root + jpa: + show-sql: true + properties: + hibernate: + dialects: org.hibernate.dialect.MySQL57Dialect + format_sql: true + default_batch_fetch_size: 1000 + generate-ddl: true + hibernate: + ddl-auto: none diff --git a/backend/pick-git/src/main/resources/application-test.yml b/backend/pick-git/src/main/resources/application-test.yml new file mode 100644 index 000000000..cec23b713 --- /dev/null +++ b/backend/pick-git/src/main/resources/application-test.yml @@ -0,0 +1,24 @@ +spring: + datasource: + url: jdbc:h2:~/test;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + username: sa + password: + jpa: + show-sql: true + properties: + hibernate: + dialects: org.hibernate.dialect.MySQL57Dialect + format_sql: true + default_batch_fetch_size: 1000 + 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 new file mode 100644 index 000000000..e57f9d610 --- /dev/null +++ b/backend/pick-git/src/main/resources/application.yml @@ -0,0 +1,3 @@ +spring: + profiles: + include: security diff --git a/backend/pick-git/src/main/resources/static/docs/index.html b/backend/pick-git/src/main/resources/static/docs/index.html new file mode 100644 index 000000000..1b493baaf --- /dev/null +++ b/backend/pick-git/src/main/resources/static/docs/index.html @@ -0,0 +1,1376 @@ + + + + + + + +Authorization + + + + + + +
+
+

Authorization

+
+
+

깃허브 로그인 url 받기

+
+

Request

+
+
+
GET /api/authorization/github HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

Response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 49
+
+{
+  "url" : "http://github.authorization.url"
+}
+
+
+
+
+
+

깃허브 로그인 후

+
+

Request

+
+
+
GET /api/afterlogin?code=random HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

Response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 55
+
+{
+  "token" : "jwt token",
+  "username" : "binghe"
+}
+
+
+
+
+
+
+
+

Comment

+
+
+

댓글 등록

+
+

Request

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

Response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 96
+
+{
+  "id" : 1,
+  "authorName" : "kevin",
+  "content" : "test comment",
+  "isLiked" : false
+}
+
+
+
+
+
+

댓글 등록 - 내용이 없는 경우

+
+

Request

+
+
+
POST /api/posts/1/comments HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: Bearer test
+Content-Length: 2
+Host: localhost:8080
+
+""
+
+
+
+
+

Response

+
+
+
HTTP/1.1 400 Bad Request
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 29
+
+{
+  "errorCode" : "F0001"
+}
+
+
+
+
+
+

댓글 등록 - 내용이 없는 경우

+
+

Request

+
+
+
POST /api/posts/1/comments HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: Bearer test
+Content-Length: 2
+Host: localhost:8080
+
+""
+
+
+
+
+

Response

+
+
+
HTTP/1.1 400 Bad Request
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 29
+
+{
+  "errorCode" : "F0001"
+}
+
+
+
+
+
+
+
+

Authorization

+
+
+

팔로잉 요청 - 로그인

+
+

Request

+
+
+
POST /api/profiles/testUser/followings HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: Bearer testToken
+Accept: */*
+Host: localhost:8080
+
+
+
+
+

Response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 50
+
+{
+  "followerCount" : 1,
+  "following" : true
+}
+
+
+
+
+
+

언팔로우 요청 - 로그인

+
+

Request

+
+
+
POST /api/profiles/testUser/followings HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: Bearer testToken
+Accept: */*
+Host: localhost:8080
+
+
+
+
+

Response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 51
+
+{
+  "followerCount" : 1,
+  "following" : false
+}
+
+
+
+
+
+

팔로잉 요청 - 비 로그인

+
+

Request

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

Response

+
+
+
HTTP/1.1 401 Unauthorized
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 29
+
+{
+  "errorCode" : "A0001"
+}
+
+
+
+
+
+

언팔로우 요청 - 비 로그인

+
+

Request

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

Response

+
+
+
HTTP/1.1 401 Unauthorized
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 29
+
+{
+  "errorCode" : "A0001"
+}
+
+
+
+
+
+
+
+

Post

+
+
+

게시물 작성 - 로그인

+
+

Request

+
+
+
POST /api/posts HTTP/1.1
+Content-Type: multipart/form-data;charset=UTF-8; boundary=6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Authorization: pickgit
+Host: localhost:8080
+
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=githubRepoUrl
+
+https://github.com/woowacourse-teams/2021-pick-git/
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=content
+
+pickgit
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=tags
+
+java
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=tags
+
+spring
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=images; filename=testImage1.jpg
+Content-Type: image/jpeg
+
+testimage1Binary
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=images; filename=testImage2.jpg
+Content-Type: image/jpeg
+
+testimage2Binary
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm--
+
+
+
+
+

Response

+
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Location: /api/posts/jipark3/1
+
+
+
+
+
+

게시물 작성 - 비 로그인

+
+

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/
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=content
+
+pickgit
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=tags
+
+java
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=tags
+
+spring
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=images; filename=testImage1.jpg
+Content-Type: image/jpeg
+
+testimage1Binary
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=images; filename=testImage2.jpg
+Content-Type: image/jpeg
+
+testimage2Binary
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm--
+
+
+
+
+

Response

+
+
+
HTTP/1.1 401 Unauthorized
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 29
+
+{
+  "errorCode" : "A0002"
+}
+
+
+
+
+
+

홈 피드 요청 - 로그인

+
+

Request

+
+
+
GET /api/posts?page=0&limit=3 HTTP/1.1
+Authorization: oauth.access.token
+Host: localhost:8080
+
+
+
+
+

Response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 518
+
+[ {
+  "id" : 1,
+  "imageUrls" : [ "iamge1Url", "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",
+  "comments" : [ {
+    "id" : 1,
+    "authorName" : "commentAuthorName",
+    "content" : "commentContent",
+    "isLiked" : false
+  } ],
+  "isLiked" : false
+} ]
+
+
+
+
+
+

홈 피드 요청 - 비 로그인

+
+

Request

+
+
+
GET /api/posts?page=0&limit=3 HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

Response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 518
+
+[ {
+  "id" : 1,
+  "imageUrls" : [ "iamge1Url", "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",
+  "comments" : [ {
+    "id" : 1,
+    "authorName" : "commentAuthorName",
+    "content" : "commentContent",
+    "isLiked" : false
+  } ],
+  "isLiked" : false
+} ]
+
+
+
+
+
+

레포지토리 요청 - 로그인

+
+

Request

+
+
+
GET /api/github/$jipark3/repositories HTTP/1.1
+Authorization: oauth.access.token
+Host: localhost:8080
+
+
+
+
+

Response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 51
+
+[ {
+  "name" : "pick"
+}, {
+  "name" : "git"
+} ]
+
+
+
+
+
+
+
+

Profile

+
+
+

내 프로필 조회

+
+

Request

+
+
+
GET /api/profiles/me HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: Bearer testToken
+Accept: */*
+Host: localhost:8080
+
+
+
+
+

Response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 350
+
+{
+  "name" : "yjksw",
+  "image" : "http://img.com",
+  "description" : "The Best",
+  "followerCount" : 0,
+  "followingCount" : 11,
+  "postCount" : 1,
+  "githubUrl" : "https://github.com/yjksw",
+  "company" : "woowacourse",
+  "location" : "Seoul",
+  "website" : "www.pick-git.com",
+  "twitter" : "pick-git twitter",
+  "following" : false
+}
+
+
+
+
+
+

다른 사용자 프로필 조회 - 로그인

+
+

Request

+
+
+
GET /api/profiles/testUser%7D HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Authorization: Bearer testToken
+Accept: */*
+Host: localhost:8080
+
+
+
+
+

Response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 350
+
+{
+  "name" : "yjksw",
+  "image" : "http://img.com",
+  "description" : "The Best",
+  "followerCount" : 0,
+  "followingCount" : 11,
+  "postCount" : 1,
+  "githubUrl" : "https://github.com/yjksw",
+  "company" : "woowacourse",
+  "location" : "Seoul",
+  "website" : "www.pick-git.com",
+  "twitter" : "pick-git twitter",
+  "following" : false
+}
+
+
+
+
+
+

다른 사용자 프로필 조회 - 비 로그인

+
+

Request

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

Response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 349
+
+{
+  "name" : "yjksw",
+  "image" : "http://img.com",
+  "description" : "The Best",
+  "followerCount" : 0,
+  "followingCount" : 11,
+  "postCount" : 1,
+  "githubUrl" : "https://github.com/yjksw",
+  "company" : "woowacourse",
+  "location" : "Seoul",
+  "website" : "www.pick-git.com",
+  "twitter" : "pick-git twitter",
+  "following" : null
+}
+
+
+
+
+
+
+
+

Tag

+
+
+

특정 유저의 태그 목록 요청

+
+

Request

+
+
+
GET /api/github/repositories/repo%7D/tags/languages HTTP/1.1
+Authorization: Bearer validtoken
+Host: localhost:8080
+
+
+
+
+

Response

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 28
+
+[ "Java", "Python", "HTML" ]
+
+
+
+
+
+

유효하지 않은 AccessToken으로 태그 추출 요청

+
+

Request

+
+
+
GET /api/github/repositories/abc%7D/tags/languages HTTP/1.1
+Authorization: Bearer invalid
+Host: localhost:8080
+
+
+
+
+

Response

+
+
+
HTTP/1.1 401 Unauthorized
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 29
+
+{
+  "errorCode" : "A0001"
+}
+
+
+
+
+
+

유효하지 않은 레포지토리 태그 추출 요청

+
+

Request

+
+
+
GET /api/github/repositories/abc%7D/tags/languages HTTP/1.1
+Authorization: Bearer validtoken
+Host: localhost:8080
+
+
+
+
+

Response

+
+
+
HTTP/1.1 500 Internal Server Error
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 29
+
+{
+  "errorCode" : "V0001"
+}
+
+
+
+

PickGit API

+
+
+
+
+
+
+ + + + + + \ No newline at end of file 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 new file mode 100644 index 000000000..a142774e9 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/PickGitApplicationTests.java @@ -0,0 +1,17 @@ +package com.woowacourse.pickgit; + +import com.woowacourse.pickgit.post.PostTestConfiguration; +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) +@SpringBootTest +@ActiveProfiles("test") +class PickGitApplicationTests { + + @Test + void contextLoads() { + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/TestJpaConfiguration.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/TestJpaConfiguration.java new file mode 100644 index 000000000..33cd36177 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/TestJpaConfiguration.java @@ -0,0 +1,10 @@ +package com.woowacourse.pickgit; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@TestConfiguration +@EnableJpaAuditing +public class TestJpaConfiguration { + +} 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/authentication/OAuthAcceptanceTest.java new file mode 100644 index 000000000..6f6ae5989 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/OAuthAcceptanceTest.java @@ -0,0 +1,106 @@ +package com.woowacourse.pickgit.authentication; + +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.OAuthLoginUrlResponse; +import com.woowacourse.pickgit.authentication.presentation.dto.OAuthTokenResponse; +import com.woowacourse.pickgit.post.PostTestConfiguration; +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 OAuthAcceptanceTest { + + @LocalServerPort + int port; + + @BeforeEach + void setUp() { + RestAssured.port = port; + } + + @MockBean + private OAuthClient oAuthClient; + + @DisplayName("로그인 - Github OAuth 로그인 URL을 요청한다.") + @Test + void Authorization_Github_ReturnLoginUrl() { + // mock + when(oAuthClient.getLoginUrl()).thenReturn("https://github.com/login/oauth/authorize?"); + + // when + OAuthLoginUrlResponse response = RestAssured + .given().log().all() + .accept(MediaType.APPLICATION_JSON_VALUE) + .when() + .get("/api/authorization/github") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract().as(OAuthLoginUrlResponse.class); + + // then + assertThat( + response.getUrl().startsWith("https://github.com/login/oauth/authorize") + ).isTrue(); + } + + @DisplayName("로그인 - Github 인증후 리다이렉션을 통해 요청이 오면 토큰을 생성하여 반환한다.") + @Test + void Authorization_Redirection_ReturnJwtToken() { + // then + OAuthTokenResponse tokenResponse = 로그인_되어있음(); + assertThat(tokenResponse.getToken()).isNotBlank(); + assertThat(tokenResponse.getUsername()).isEqualTo("pick-git-login"); + } + + public OAuthTokenResponse 로그인_되어있음() { + OAuthTokenResponse response = 로그인_요청().as(OAuthTokenResponse.class); + assertThat(response.getToken()).isNotBlank(); + return response; + } + + public ExtractableResponse 로그인_요청() { + // 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); + + // 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 new file mode 100644 index 000000000..267b065e4 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/application/OAuthServiceMockTest.java @@ -0,0 +1,178 @@ +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 new file mode 100644 index 000000000..d98d95ff9 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/application/OAuthServiceTest.java @@ -0,0 +1,142 @@ +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 new file mode 100644 index 000000000..dcf1b4e8b --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/dao/OAuthAccessTokenDaoTest.java @@ -0,0 +1,51 @@ +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/authentication/presentation/AuthenticationPrincipalArgumentResolverTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/presentation/AuthenticationPrincipalArgumentResolverTest.java new file mode 100644 index 000000000..bbe9913fd --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/presentation/AuthenticationPrincipalArgumentResolverTest.java @@ -0,0 +1,117 @@ +package com.woowacourse.pickgit.authentication.presentation; + +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.JwtTokenProvider; +import com.woowacourse.pickgit.authentication.application.OAuthService; +import com.woowacourse.pickgit.authentication.dao.CollectionOAuthAccessTokenDao; +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.interceptor.AuthHeader; +import com.woowacourse.pickgit.exception.authentication.InvalidTokenException; +import com.woowacourse.pickgit.exception.authentication.UnauthorizedException; +import javax.servlet.http.HttpServletRequest; +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; +import org.springframework.http.HttpHeaders; +import org.springframework.web.context.request.ServletWebRequest; + +@ExtendWith(MockitoExtension.class) +class AuthenticationPrincipalArgumentResolverTest { + + private JwtTokenProvider jwtTokenProvider; + + private OAuthAccessTokenDao oAuthAccessTokenDao; + + @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); + } + + @DisplayName("유효한 토큰이면 LoginUser를 반환한다.") + @Test + void resolveArgument_ValidUserToken_ReturnLoginUser() throws Exception { + // given + String username = "pick-git"; + String accessToken = "oauth access token"; + String jwtToken = jwtTokenProvider.createToken(username); + + oAuthAccessTokenDao.insert(jwtToken, accessToken); + + // mock + when(httpServletRequest.getAttribute(AuthHeader.AUTHENTICATION)).thenReturn(jwtToken); + + // when + AppUser loginUser = (AppUser) authenticationPrincipalArgumentResolver.resolveArgument(null, null, new ServletWebRequest(httpServletRequest), null); + + // then + assertThat(loginUser.isGuest()).isFalse(); + assertThat(loginUser.getUsername()).isEqualTo(username); + assertThat(loginUser.getAccessToken()).isEqualTo(accessToken); + } + + @DisplayName("유효하지 않은 토큰이면 예외가 발생한다.") + @Test + void resolveArgument_InvalidToken_ThrowException() throws Exception { + // given + String jwtToken = "invalid jwt token"; + + // mock + when(httpServletRequest.getAttribute(AuthHeader.AUTHENTICATION)).thenReturn(jwtToken); + + // then + assertThatThrownBy(() -> { + authenticationPrincipalArgumentResolver.resolveArgument(null, null, new ServletWebRequest(httpServletRequest), null); + }).isInstanceOf(InvalidTokenException.class); + } + + @DisplayName("AccessToken DB에 저장되어 있지 않은 토큰이라면 예외가 발생한다.") + @Test + void resolveArgument_NotFoundToken_ThrowException() { + // given + String jwtToken = jwtTokenProvider.createToken("pick-git"); + + // when + when(httpServletRequest.getAttribute(AuthHeader.AUTHENTICATION)).thenReturn(jwtToken); + + assertThatThrownBy(() -> { + authenticationPrincipalArgumentResolver.resolveArgument(null, null, new ServletWebRequest(httpServletRequest), null); + }).isInstanceOf(InvalidTokenException.class); + } + + @DisplayName("요청 헤더에 authorization을 추가해주지 않으면 Guest가 반환된다.") + @Test + void resolveArgument_InValidUserToken_ReturnGuest() throws Exception { + // mock + when(httpServletRequest.getAttribute(AuthHeader.AUTHENTICATION)).thenReturn(null); + + // when + AppUser loginUser = (AppUser) authenticationPrincipalArgumentResolver.resolveArgument(null, null, new ServletWebRequest(httpServletRequest), null); + + // then + assertThat(loginUser.isGuest()).isTrue(); + assertThatThrownBy(() -> loginUser.getAccessToken()) + .isInstanceOf(UnauthorizedException.class); + assertThatThrownBy(() -> loginUser.getUsername()) + .isInstanceOf(UnauthorizedException.class); + } +} 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/authentication/presentation/OAuthControllerTest.java new file mode 100644 index 000000000..6890046e2 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/presentation/OAuthControllerTest.java @@ -0,0 +1,87 @@ +package com.woowacourse.pickgit.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.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +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.test.web.servlet.request.MockMvcRequestBuilders.get; +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.application.dto.TokenDto; +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 { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private OAuthService oAuthService; + + @DisplayName("Github 로그인 요청을 하면 Github 인증 URL을 반환한다.") + @Test + void authorizationGithubUrl_InvalidAccount_GithubUrl() throws Exception { + // given + String githubAuthorizationGithubUrl = "http://github.authorization.url"; + when(oAuthService.getGithubAuthorizationUrl()).thenReturn(githubAuthorizationGithubUrl); + + // when, then + ResultActions perform = mockMvc.perform(get("/api/authorization/github")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("url").value(githubAuthorizationGithubUrl)); + + perform.andDo(document("authorization - githubLogin", + getDocumentRequest(), + getDocumentResponse(), + responseFields( + fieldWithPath("url").type(STRING).description("Github login url") + ) + )); + } + + @DisplayName("Github 로그인 인증 후 토큰을 발행하여 반환한다.") + @Test + void afterAuthorizeGithubLogin_ValidAccount_JWTToken() throws Exception { + // given + String githubAuthorizationCode = "random"; + when(oAuthService.createToken(githubAuthorizationCode)) + .thenReturn(new TokenDto("jwt token", "binghe")); + + // when, then + ResultActions perform = mockMvc + .perform(get("/api/afterlogin?code=" + githubAuthorizationCode)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("token").value("jwt token")); + + perform.andDo(document("authorization - afterlogin", + getDocumentRequest(), + getDocumentResponse(), + responseFields( + fieldWithPath("token").type(STRING).description("JWT 토큰"), + fieldWithPath("username").type(STRING).description("유저 이름") + ) + )); + } +} 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/authentication/presentation/interceptor/AuthenticationInterceptorTest.java new file mode 100644 index 000000000..8e3b7c80d --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/presentation/interceptor/AuthenticationInterceptorTest.java @@ -0,0 +1,108 @@ +package com.woowacourse.pickgit.authentication.presentation.interceptor; + +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.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.exception.authentication.InvalidTokenException; +import java.util.Collections; +import java.util.List; +import javax.servlet.http.HttpServletRequest; +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; +import org.springframework.http.HttpMethod; + +@ExtendWith(MockitoExtension.class) +class AuthenticationInterceptorTest { + + private JwtTokenProvider jwtTokenProvider; + + private OAuthService oAuthService; + + @Mock + private HttpServletRequest httpServletRequest; + + @InjectMocks + private AuthenticationInterceptor authenticationInterceptor; + + @BeforeEach + void setUp() { + jwtTokenProvider = new JwtTokenProviderImpl("pick-git", 3600000); + oAuthService = new OAuthService(null, jwtTokenProvider, null, null); + authenticationInterceptor = new AuthenticationInterceptor(oAuthService); + } + + @DisplayName("CORS 프리플라이트 요청이면 true를 반환한다.") + @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"); + + // then + assertThat(authenticationInterceptor.preHandle(httpServletRequest, null, null)).isTrue(); + } + + @DisplayName("유효한 토큰의 요청이면 HttpServletRequest에 토큰 정보를 저장하고 true를 반환한다.") + @Test + void preHandle_ValidToken_True() throws Exception { + // given + 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))); + + // then + assertThat(authenticationInterceptor.preHandle(httpServletRequest, null, null)).isTrue(); + verify(httpServletRequest, times(2)).setAttribute(any(String.class), any(String.class)); + } + + @DisplayName("유효하지 않은 토큰의 요청이면 예외를 던진다.") + @Test + void preHandle_InvalidToken_ThrowException() { + // given + String bearerToken = "Bearer " + "invalid token"; + + // mock + when(httpServletRequest.getMethod()).thenReturn(HttpMethod.OPTIONS.toString()); + when(httpServletRequest.getHeaders(AuthorizationExtractor.AUTHORIZATION)).thenReturn(Collections.enumeration( + List.of(bearerToken))); + + // when, then + assertThatThrownBy(() -> authenticationInterceptor.preHandle(httpServletRequest, null, null)) + .isInstanceOf(InvalidTokenException.class); + } + + @DisplayName("유효기간이 지난 토큰의 경우 예외가 발생한다.") + @Test + void preHandle_ExpiredToken_ThrowException() { + // given + jwtTokenProvider.changeExpirationTime(0); + 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))); + + // when, then + assertThatThrownBy(() -> authenticationInterceptor.preHandle(httpServletRequest, null, null)) + .isInstanceOf(InvalidTokenException.class); + } +} 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/authentication/presentation/interceptor/IgnoreAuthenticationInterceptorTest.java new file mode 100644 index 000000000..2e312c8a6 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/presentation/interceptor/IgnoreAuthenticationInterceptorTest.java @@ -0,0 +1,92 @@ +package com.woowacourse.pickgit.authentication.presentation.interceptor; + +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.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.exception.authentication.InvalidTokenException; +import java.util.Collections; +import java.util.List; +import javax.servlet.http.HttpServletRequest; +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; +import org.springframework.http.HttpMethod; + +@ExtendWith(MockitoExtension.class) +class IgnoreAuthenticationInterceptorTest { + + private JwtTokenProvider jwtTokenProvider; + + private OAuthService oAuthService; + + @Mock + private HttpServletRequest request; + + @InjectMocks + private IgnoreAuthenticationInterceptor ignoreAuthenticationInterceptor; + + @BeforeEach + void setUp() { + jwtTokenProvider = new JwtTokenProviderImpl("pick-git", 3600000); + oAuthService = new OAuthService(null, jwtTokenProvider, null, null); + ignoreAuthenticationInterceptor = new IgnoreAuthenticationInterceptor(oAuthService); + } + + @DisplayName("유효한 토큰을 가진 회원이면 true를 반환한다.") + @Test + void preHandle_WithValidToken_ReturnTrue() throws Exception { + // given + String validToken = "Bearer " + jwtTokenProvider.createToken("pick-git"); + + // mock + given(request.getMethod()).willReturn(HttpMethod.GET.toString()); + given(request.getHeaders(AuthorizationExtractor.AUTHORIZATION)) + .willReturn(Collections.enumeration(List.of(validToken))); + + // when, then + assertThat(ignoreAuthenticationInterceptor.preHandle(request, null, null)).isTrue(); + verify(request, times(2)).setAttribute(anyString(), anyString()); + } + + @DisplayName("토큰이 아예 없으면 true를 반환한다. (GuestUser)") + @Test + void preHandle_WithOutToken_ReturnTrue() throws Exception { + // mock + given(request.getMethod()).willReturn(HttpMethod.GET.toString()); + given(request.getHeaders(AuthorizationExtractor.AUTHORIZATION)) + .willReturn(Collections.emptyEnumeration()); + + // when, then + assertThat(ignoreAuthenticationInterceptor.preHandle(request, null, null)).isTrue(); + } + + @DisplayName("토큰이 존재하지만 유효하지 않으면 InvalidTokenException을 던진다.") + @Test + void preHandle_WithInvalidToken_ThrowException() { + // given + String invalidToken = "Bearer invalid token"; + + // mock + given(request.getMethod()).willReturn(HttpMethod.GET.toString()); + given(request.getHeaders(AuthorizationExtractor.AUTHORIZATION)) + .willReturn(Collections.enumeration(List.of(invalidToken))); + + // when, then + assertThatThrownBy(() -> ignoreAuthenticationInterceptor.preHandle(request, null, null)) + .isInstanceOf(InvalidTokenException.class) + .extracting("errorCode") + .isEqualTo("A0001"); + } +} 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/FileFactory.java new file mode 100644 index 000000000..2b2189678 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/common/FileFactory.java @@ -0,0 +1,53 @@ +package com.woowacourse.pickgit.common; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +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"; + + public static MockMultipartFile getTestImage1() { + return createImageFile("testImage1.png"); + } + + public static MockMultipartFile getTestImage2() { + return createImageFile("testImage2.png"); + } + + public static File getTestImage1File() { + return createFile("testImage1.png"); + } + + public static File getTestImage2File() { + return createFile("testImage2.png"); + } + + private static MockMultipartFile createImageFile(String fileName) { + File file = createFile(fileName); + + try { + return new MockMultipartFile( + FILE_KEY, + fileName, + ContentType.IMAGE_JPEG.getMimeType(), + Files.readAllBytes(file.toPath()) + ); + } catch (IOException e) { + throw new RuntimeException(); + } + } + + private static File createFile(String fileName) { + URL resource = CLASS_LOADER.getResource(fileName); + Objects.requireNonNull(resource); + + return new File(resource.getFile()); + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/docs/ApiDocumentUtils.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/docs/ApiDocumentUtils.java new file mode 100644 index 000000000..d1c4d71ae --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/docs/ApiDocumentUtils.java @@ -0,0 +1,20 @@ +package com.woowacourse.pickgit.docs; + +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; + +import org.springframework.restdocs.operation.preprocess.OperationRequestPreprocessor; +import org.springframework.restdocs.operation.preprocess.OperationResponsePreprocessor; + +public interface ApiDocumentUtils { + + static OperationRequestPreprocessor getDocumentRequest() { + return preprocessRequest(prettyPrint()); + } + + static OperationResponsePreprocessor getDocumentResponse() { + return preprocessResponse(prettyPrint()); + } + +} 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 new file mode 100644 index 000000000..62f095af7 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/PostAcceptanceTest.java @@ -0,0 +1,443 @@ +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 new file mode 100644 index 000000000..99e3e3bb2 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/PostTestConfiguration.java @@ -0,0 +1,15 @@ +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 new file mode 100644 index 000000000..8916a23d4 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/application/PostFactory.java @@ -0,0 +1,98 @@ +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 new file mode 100644 index 000000000..437488c73 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/application/PostServiceIntegrationTest.java @@ -0,0 +1,318 @@ +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 new file mode 100644 index 000000000..a18e4c899 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/application/PostServiceTest.java @@ -0,0 +1,240 @@ +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/PostContentTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/domain/PostContentTest.java new file mode 100644 index 000000000..6eae043cf --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/domain/PostContentTest.java @@ -0,0 +1,23 @@ +package com.woowacourse.pickgit.post.domain; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.woowacourse.pickgit.exception.post.PostFormatException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class PostContentTest { + + @DisplayName("게시물 내용이 500자 초과인 경우 예외가 발생한다.") + @Test + void validate_IsOver500_ThrowsException() { + // given + String content = "hi".repeat(500); + + // then + assertThatThrownBy(() -> { new PostContent(content); }) + .isInstanceOf(PostFormatException.class) + .extracting("errorCode") + .isEqualTo("F0001"); + } +} 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 new file mode 100644 index 000000000..dc673e52a --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/domain/PostRepositoryTest.java @@ -0,0 +1,144 @@ +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 new file mode 100644 index 000000000..3222e5eb3 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/domain/PostTest.java @@ -0,0 +1,45 @@ +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/domain/comment/CommentTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/domain/comment/CommentTest.java new file mode 100644 index 000000000..5e4af80aa --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/domain/comment/CommentTest.java @@ -0,0 +1,48 @@ +package com.woowacourse.pickgit.post.domain.comment; + +import static org.assertj.core.api.Assertions.assertThatCode; + +import com.woowacourse.pickgit.exception.post.CommentFormatException; +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; + +class CommentTest { + + @DisplayName("100자 이하의 댓글을 생성할 수 있다.") + @Test + void newComment_Under100Length_Success() { + StringBuilder content = new StringBuilder(); + for (int i = 0; i < 100; i++) { + content.append("a"); + } + + assertThatCode(() -> new Comment(content.toString())) + .doesNotThrowAnyException(); + } + + @DisplayName("100자 초과의 댓글을 생성할 수 없다.") + @Test + void newComment_Over100Length_ExceptionThrown() { + StringBuilder content = new StringBuilder(); + for (int i = 0; i < 101; i++) { + content.append("a"); + } + + assertThatCode(() -> new Comment(content.toString())) + .isInstanceOf(CommentFormatException.class) + .extracting("errorCode") + .isEqualTo("F0002"); + } + + @DisplayName("댓글은 null이거나 빈 문자열이어서는 안 된다.") + @ParameterizedTest + @NullAndEmptySource + void newComment_NullOrEmpty_ExceptionThrown(String content) { + assertThatCode(() -> new Comment(content)) + .isInstanceOf(CommentFormatException.class) + .extracting("errorCode") + .isEqualTo("F0002"); + } +} 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/post/infrastructure/GithubRepositoryExtractorTest.java new file mode 100644 index 000000000..3364b1c0b --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/infrastructure/GithubRepositoryExtractorTest.java @@ -0,0 +1,62 @@ +package com.woowacourse.pickgit.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.exception.platform.PlatformHttpErrorException; +import com.woowacourse.pickgit.post.domain.PlatformRepositoryExtractor; +import com.woowacourse.pickgit.post.domain.dto.RepositoryResponseDto; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GithubRepositoryExtractorTest { + + private static final String ACCESS_TOKEN = "oauth.access.token"; + private static final String USERNAME = "jipark3"; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private PlatformRepositoryExtractor platformRepositoryExtractor; + + @BeforeEach + void setUp() { + platformRepositoryExtractor = + new GithubRepositoryExtractor(objectMapper, new MockRepositoryApiRequester()); + } + + @DisplayName("Public Repository 목록을 가져온다.") + @Test + void extract_LoginUser_Success() { + // when + List responsesDto = + platformRepositoryExtractor.extract(ACCESS_TOKEN, USERNAME); + + // then + assertThat(responsesDto).hasSize(2); + } + + @DisplayName("토큰이 유효하지 않은 경우 예외가 발생한다. - 500 예외") + @Test + void extract_InvalidAccessToken_401Exception() { + // then + assertThatThrownBy(() -> { + platformRepositoryExtractor.extract(ACCESS_TOKEN + "hi", USERNAME); + }).isInstanceOf(PlatformHttpErrorException.class) + .extracting("errorCode") + .isEqualTo("V0001"); + } + + @DisplayName("사용자가 유효하지 않은 경우 예외가 발생한다. - 500 예외") + @Test + void extract_InvalidUserName_404Exception() { + // then + assertThatThrownBy(() -> { + platformRepositoryExtractor.extract(ACCESS_TOKEN, USERNAME + "hi"); + }).isInstanceOf(PlatformHttpErrorException.class) + .extracting("errorCode") + .isEqualTo("V0001"); + } +} 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/post/infrastructure/MockRepositoryApiRequester.java new file mode 100644 index 000000000..52ebc5d81 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/infrastructure/MockRepositoryApiRequester.java @@ -0,0 +1,32 @@ +package com.woowacourse.pickgit.post.infrastructure; + +import com.woowacourse.pickgit.exception.platform.PlatformHttpErrorException; + +public class MockRepositoryApiRequester implements PlatformRepositoryApiRequester { + + private static final String API_URL_FORMAT = "https://api.github.com/users/%s/repos"; + private static final String USERNAME = "jipark3"; + private static final String ACCESS_TOKEN = "oauth.access.token"; + + @Override + public String request(String token, String url) { + String apiUrl = String.format(API_URL_FORMAT, USERNAME); + + if (isNotValidToken(token)) { + throw new PlatformHttpErrorException("외부 플랫폼 토큰 인증 실패"); + } + if (isNotValidUrl(url, apiUrl)) { + throw new PlatformHttpErrorException("외부 플랫폼 URL NotFound"); + } + + return "[{\"name\": \"binghe-hi\" }, {\"name\": \"doms-react\" }]"; + } + + private boolean isNotValidToken(String token) { + return !token.equals(ACCESS_TOKEN); + } + + private boolean isNotValidUrl(String url, String apiUrl) { + return !url.equals(apiUrl); + } +} 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 new file mode 100644 index 000000000..d1b24b928 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/infrastructure/S3StorageTest.java @@ -0,0 +1,48 @@ +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 new file mode 100644 index 000000000..587c657fd --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/presentation/PostControllerTest.java @@ -0,0 +1,404 @@ +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/TagAcceptanceTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/TagAcceptanceTest.java new file mode 100644 index 000000000..89b1d627e --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/TagAcceptanceTest.java @@ -0,0 +1,128 @@ +package com.woowacourse.pickgit.tag; + +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.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; +import io.restassured.response.Response; +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({TestTagConfiguration.class, PostTestConfiguration.class}) +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@DirtiesContext(classMode = ClassMode.BEFORE_EACH_TEST_METHOD) +@ActiveProfiles("test") +class TagAcceptanceTest { + + @LocalServerPort + private int port; + + @MockBean + private OAuthClient oAuthClient; + + private String accessToken; + private String userName = "jipark3"; + private String repositoryName = "doms-react"; + + @BeforeEach + void setUp() { + RestAssured.port = port; + OAuthTokenResponse tokenResponse = 로그인_되어있음(); + accessToken = tokenResponse.getToken(); + } + + private ExtractableResponse requestTags(String accessToken, String url, + HttpStatus httpStatus) { + return RestAssured.given().log().all() + .auth().oauth2(accessToken) + .when().get(url) + .then().log().all() + .statusCode(httpStatus.value()) + .extract(); + } + + @DisplayName("특정 User의 Repository에 기술된 언어 태그들을 추출한다.") + @Test + void extractLanguageTags_ValidRepository_ExtractionSuccess() { + String url = + "/api/github/repositories/" + repositoryName + "/tags/languages"; + + List response = requestTags(accessToken, url, HttpStatus.OK) + .as(new TypeRef>() {}); + + assertThat(response).containsExactly("JavaScript", "HTML", "CSS"); + } + + @DisplayName("유효하지 않은 레포지토리 태그 추출 요청시 500 예외 메시지가 반환된다.") + @Test + void extractLanguageTags_InvalidRepository_ExceptionThrown() { + String url = + "/api/github/repositories/none-available-repo/tags/languages"; + + ApiErrorResponse response = requestTags(accessToken, url, HttpStatus.INTERNAL_SERVER_ERROR) + .as(ApiErrorResponse.class); + + assertThat(response.getErrorCode()).isEqualTo("V0001"); + } + + @DisplayName("유효하지 않은 AccessToken으로 태그 추출 요청시 서버 에러가 발생한다.") + @Test + void extractLanguageTags_InvalidAccessToken_ExceptionThrown() { + String url = + "/api/github/repositories/" + repositoryName + "/tags/languages"; + + requestTags("invalidtoken", url, HttpStatus.UNAUTHORIZED); + } + + private OAuthTokenResponse 로그인_되어있음() { + OAuthTokenResponse response = 로그인_요청().as(OAuthTokenResponse.class); + assertThat(response.getToken()).isNotBlank(); + return response; + } + + private ExtractableResponse 로그인_요청() { + // given + String oauthCode = "1234"; + String accessToken = "oauth.access.token"; + + OAuthProfileResponse oAuthProfileResponse = new OAuthProfileResponse( + "jipark3", "image", "hi~", "github.com/", + null, null, null, null + ); + + // 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/tag/TestTagConfiguration.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/TestTagConfiguration.java new file mode 100644 index 000000000..a34bb04ab --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/TestTagConfiguration.java @@ -0,0 +1,15 @@ +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/tag/application/TagServiceIntegrationTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/application/TagServiceIntegrationTest.java new file mode 100644 index 000000000..05fc86206 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/application/TagServiceIntegrationTest.java @@ -0,0 +1,108 @@ +package com.woowacourse.pickgit.tag.application; + +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.exception.platform.PlatformHttpErrorException; +import com.woowacourse.pickgit.exception.post.TagFormatException; +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +@DataJpaTest +class TagServiceIntegrationTest { + + private TagService tagService; + + @Autowired + private TagRepository tagRepository; + + private String accessToken = "oauth.access.token"; + private String userName = "jipark3"; + private String repositoryName = "doms-react"; + private ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeEach + void setUp() { + PlatformTagExtractor platformTagExtractor = + new GithubTagExtractor(new MockTagApiRequester(), objectMapper); + tagService = new TagService(platformTagExtractor, tagRepository); + } + + @DisplayName("Repository에 포함된 언어 태그를 추출한다.") + @Test + void extractTags_ValidRepository_ExtractionSuccess() { + ExtractionRequestDto extractionRequestDto = + new ExtractionRequestDto(accessToken, userName, repositoryName); + List tags = Arrays.asList("JavaScript", "HTML", "CSS"); + + TagsDto tagsDto = tagService.extractTags(extractionRequestDto); + + assertThat(tagsDto.getTags()).containsAll(tags); + } + + @DisplayName("잘못된 경로로 태그 추출 요청시 404 예외가 발생한다.") + @Test + void extractTags_InvalidUrl_ExceptionThrown() { + String userName = "nonuser"; + String repositoryName = "nonrepo"; + ExtractionRequestDto extractionRequestDto = + new ExtractionRequestDto(accessToken, userName, repositoryName); + + assertThatCode(() -> tagService.extractTags(extractionRequestDto)) + .isInstanceOf(PlatformHttpErrorException.class) + .extracting("errorCode") + .isEqualTo("V0001"); + } + + @DisplayName("유효하지 않은 토큰으로 태그 추출 요청시 401 예외가 발생한다.") + @Test + void extractTags_InvalidToken_ExceptionThrown() { + String accessToken = "invalidtoken"; + ExtractionRequestDto extractionRequestDto = + new ExtractionRequestDto(accessToken, userName, repositoryName); + + assertThatCode(() -> tagService.extractTags(extractionRequestDto)) + .isInstanceOf(PlatformHttpErrorException.class) + .extracting("errorCode") + .isEqualTo("V0001"); + } + + @DisplayName("태그 이름을 태그로 변환한다.") + @Test + void findOrCreateTags_ValidTag_TransformationSuccess() { + tagRepository.save(new Tag("tag3")); + List tagNames = Arrays.asList("tag1", "tag2", "tag3"); + TagsDto tagsDto = new TagsDto(tagNames); + + List tags = tagService.findOrCreateTags(tagsDto) + .stream() + .map(Tag::getName) + .collect(Collectors.toList()); + + assertThat(tags).containsAll(tagNames); + } + + @DisplayName("잘못된 태그 이름을 태그로 변환 시도시 실패한다.") + @Test + void findOrCreateTags_InvalidTagName_ExceptionThrown() { + List tagNames = Arrays.asList("tag1", "tag2", ""); + TagsDto tagsDto = new TagsDto(tagNames); + + assertThatCode(() -> tagService.findOrCreateTags(tagsDto)) + .isInstanceOf(TagFormatException.class) + .extracting("errorCode") + .isEqualTo("F0003"); + } +} 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/tag/application/TagServiceTest.java new file mode 100644 index 000000000..4a37f5b65 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/application/TagServiceTest.java @@ -0,0 +1,137 @@ +package com.woowacourse.pickgit.tag.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +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.exception.platform.PlatformHttpErrorException; +import com.woowacourse.pickgit.exception.post.TagFormatException; +import com.woowacourse.pickgit.tag.domain.PlatformTagExtractor; +import com.woowacourse.pickgit.tag.domain.Tag; +import com.woowacourse.pickgit.tag.domain.TagRepository; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +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; + +@ExtendWith(MockitoExtension.class) +class TagServiceTest { + + @InjectMocks + private TagService tagService; + + @Mock + private PlatformTagExtractor platformTagExtractor; + + @Mock + private TagRepository tagRepository; + + private String accessToken = "abc"; + private String userName = "asap"; + private String repositoryName = "next-level"; + + @DisplayName("Repository에 포함된 언어 태그를 추출한다.") + @Test + void extractTags_ValidRepository_ExtractionSuccess() { + ExtractionRequestDto extractionRequestDto = + new ExtractionRequestDto(accessToken, userName, repositoryName); + List tags = Arrays.asList("Java", "HTML", "CSS"); + + given(platformTagExtractor.extractTags(accessToken, userName, repositoryName)) + .willReturn(tags); + + TagsDto tagsDto = tagService.extractTags(extractionRequestDto); + + assertThat(tagsDto.getTags()).containsAll(tags); + verify(platformTagExtractor, times(1)).extractTags(accessToken, userName, repositoryName); + } + + @DisplayName("잘못된 경로로 태그 추출 요청시 404 예외가 발생한다.") + @Test + void extractTags_InvalidUrl_ExceptionThrown() { + String userName = "nonuser"; + String repositoryName = "nonrepo"; + ExtractionRequestDto extractionRequestDto = + new ExtractionRequestDto(accessToken, userName, repositoryName); + + given(platformTagExtractor.extractTags(accessToken, userName, repositoryName)) + .willThrow(new PlatformHttpErrorException()); + + assertThatCode(() -> tagService.extractTags(extractionRequestDto)) + .isInstanceOf(PlatformHttpErrorException.class) + .extracting("errorCode") + .isEqualTo("V0001"); + verify(platformTagExtractor, times(1)) + .extractTags(accessToken, userName, repositoryName); + } + + @DisplayName("유효하지 않은 토큰으로 태그 추출 요청시 401 예외가 발생한다.") + @Test + void extractTags_InvalidToken_ExceptionThrown() { + String accessToken = "invalidtoken"; + ExtractionRequestDto extractionRequestDto = + new ExtractionRequestDto(accessToken, userName, repositoryName); + + given(platformTagExtractor.extractTags(accessToken, userName, repositoryName)) + .willThrow(new PlatformHttpErrorException()); + + assertThatCode(() -> tagService.extractTags(extractionRequestDto)) + .isInstanceOf(PlatformHttpErrorException.class) + .extracting("errorCode") + .isEqualTo("V0001"); + verify(platformTagExtractor, times(1)) + .extractTags(accessToken, userName, repositoryName); + } + + @DisplayName("태그 이름을 태그로 변환한다.") + @Test + void findOrCreateTags_ValidTag_TransformationSuccess() { + List tagNames = Arrays.asList("tag1", "tag2", "tag3"); + TagsDto tagsDto = new TagsDto(tagNames); + + given(tagRepository.findByName("tag1")) + .willReturn(Optional.empty()); + given(tagRepository.findByName("tag2")) + .willReturn(Optional.empty()); + given(tagRepository.findByName("tag3")) + .willReturn(Optional.of(new Tag("tag3"))); + + List tags = tagService.findOrCreateTags(tagsDto) + .stream() + .map(Tag::getName) + .collect(Collectors.toList()); + + assertThat(tags).containsAll(tagNames); + verify(tagRepository, times(3)).findByName(anyString()); + } + + @DisplayName("잘못된 태그 이름을 태그로 변환 시도시 실패한다.") + @Test + void findOrCreateTags_InvalidTagName_ExceptionThrown() { + List tagNames = Arrays.asList("tag1", "tag2", ""); + TagsDto tagsDto = new TagsDto(tagNames); + + given(tagRepository.findByName("tag1")) + .willReturn(Optional.empty()); + given(tagRepository.findByName("tag2")) + .willReturn(Optional.empty()); + given(tagRepository.findByName("")) + .willReturn(Optional.empty()); + + assertThatCode(() -> tagService.findOrCreateTags(tagsDto)) + .isInstanceOf(TagFormatException.class) + .extracting("errorCode") + .isEqualTo("F0003"); + verify(tagRepository, times(3)).findByName(anyString()); + } +} 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/tag/domain/TagTest.java new file mode 100644 index 000000000..51e05d13f --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/domain/TagTest.java @@ -0,0 +1,31 @@ +package com.woowacourse.pickgit.tag.domain; + +import static org.assertj.core.api.Assertions.assertThatCode; + +import com.woowacourse.pickgit.exception.post.TagFormatException; +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 TagTest { + + @DisplayName("태그를 정상 생성한다.") + @Test + void newTag_ValidName_Success() { + assertThatCode(() -> new Tag("abc")) + .doesNotThrowAnyException(); + } + + @DisplayName("태그 이름이 null이거나 빈 문자열이거나 20자를 넘어가면 예외가 발생한다.") + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {"abcdeabcdeabcdeabcdea"}) + void newTag_InvalidName_Failure(String name) { + assertThatCode(() -> new Tag(name)) + .isInstanceOf(TagFormatException.class) + .extracting("errorCode") + .isEqualTo("F0003"); + } +} 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/tag/infrastructure/GithubTagExtractorTest.java new file mode 100644 index 000000000..ae9f1aef8 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/infrastructure/GithubTagExtractorTest.java @@ -0,0 +1,57 @@ +package com.woowacourse.pickgit.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.exception.platform.PlatformHttpErrorException; +import com.woowacourse.pickgit.tag.domain.PlatformTagExtractor; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GithubTagExtractorTest { + + private static final String TESTER_ACCESS_TOKEN = "oauth.access.token"; + private static final String USER_NAME = "jipark3"; + private static final String REPOSITORY_NAME = "doms-react"; + + private PlatformTagExtractor platformTagExtractor; + private ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeEach + void setUp() { + PlatformApiRequester platformApiRequester = new MockTagApiRequester(); + platformTagExtractor = new GithubTagExtractor(platformApiRequester, objectMapper); + } + + @DisplayName("명시된 User의 Repository에 기술된 Language Tags를 추출한다.") + @Test + void extractTags_ValidRepository_ExtractionSuccess() { + List tags = platformTagExtractor + .extractTags(TESTER_ACCESS_TOKEN, USER_NAME, REPOSITORY_NAME); + + assertThat(tags).contains("JavaScript", "HTML", "CSS"); + } + + @DisplayName("토큰이 유효하지 않은 경우 권한 예외가 발생한다.") + @Test + void extractTags_InvalidAccessToken_ExceptionThrown() { + assertThatCode(() -> { + platformTagExtractor.extractTags("invalidTOken", USER_NAME, REPOSITORY_NAME); + }).isInstanceOf(PlatformHttpErrorException.class) + .extracting("errorCode") + .isEqualTo("V0001"); + } + + @DisplayName("username/repositoryname 링크에 해당하는 경로가 존재하지 않으면 조회 예외가 발생한다.") + @Test + void extractTags_InvalidUrl_ExceptionThrown() { + assertThatCode(() -> { + platformTagExtractor.extractTags(TESTER_ACCESS_TOKEN, "invalidpath", REPOSITORY_NAME); + }).isInstanceOf(PlatformHttpErrorException.class) + .extracting("errorCode") + .isEqualTo("V0001"); + } +} 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/tag/infrastructure/MockTagApiRequester.java new file mode 100644 index 000000000..5e2f53be1 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/infrastructure/MockTagApiRequester.java @@ -0,0 +1,23 @@ +package com.woowacourse.pickgit.tag.infrastructure; + +import com.woowacourse.pickgit.exception.platform.PlatformHttpErrorException; + +public class MockTagApiRequester implements PlatformApiRequester{ + + private static final String TESTER_ACCESS_TOKEN = "oauth.access.token"; + private static final String USER_NAME = "jipark3"; + private static final String REPOSITORY_NAME = "doms-react"; + + @Override + public String requestTags(String url, String accessToken) { + String validUrl = + "https://api.github.com/repos/" + USER_NAME + "/" + REPOSITORY_NAME + "/languages"; + if (!accessToken.equals(TESTER_ACCESS_TOKEN)) { + throw new PlatformHttpErrorException(); + } + if (!url.equals(validUrl)) { + throw new PlatformHttpErrorException(); + } + return "{\"JavaScript\": \"91949\", \"HTML\": \"13\", \"CSS\": \"9\"}"; + } +} 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/tag/presentation/TagControllerTest.java new file mode 100644 index 000000000..3b1dee6b4 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/presentation/TagControllerTest.java @@ -0,0 +1,171 @@ +package com.woowacourse.pickgit.tag.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.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.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; + +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.exception.platform.PlatformHttpErrorException; +import com.woowacourse.pickgit.tag.application.ExtractionRequestDto; +import com.woowacourse.pickgit.tag.application.TagService; +import com.woowacourse.pickgit.tag.application.TagsDto; +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 { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private TagService tagService; + + @MockBean + private OAuthService oAuthService; + + private String accessToken = "Bearer validtoken"; + private String userName = "abc"; + private String repositoryName = "repo"; + + @BeforeEach + void setUp() { + LoginUser loginUser = new LoginUser(userName, accessToken); + given(oAuthService.validateToken(any())) + .willReturn(true); + given(oAuthService.findRequestUserByToken(any())) + .willReturn(loginUser); + } + + @DisplayName("특정 User의 Repository에 기술된 언어 태그들을 추출한다.") + @Test + void extractLanguageTags_ValidRepository_ExtractionSuccess() throws Exception { + 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()); + + given(tagService.extractTags(any(ExtractionRequestDto.class))) + .willReturn(tagsDto); + + ResultActions perform = mockMvc.perform(get(url, repositoryName) + .header("Authorization", accessToken)) + .andExpect(status().isOk()) + .andExpect(content().string(expectedResponse)); + + verify(tagService, times(1)) + .extractTags(any(ExtractionRequestDto.class)); + + perform.andDo(document("tag-extractTagFromRepositoryOfSpecificUser", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("bearer token") + ), + pathParameters( + parameterWithName("repositoryName").description("레포지토리 이름") + ), + responseFields( + fieldWithPath("[]").type(ARRAY).description("태그 목록") + ) + )); + } + + @DisplayName("유효하지 않은 AccessToken으로 태그 추출 요청시 401 예외 메시지가 반환된다.") + @Test + void extractLanguageTags_InvalidAccessToken_ExceptionThrown() throws Exception { + String url = + "/api/github/repositories/{repositoryName}}/tags/languages"; + + given(oAuthService.validateToken(any(String.class))) + .willReturn(false); + + ResultActions perform = mockMvc.perform(get(url, userName) + .header("Authorization", "Bearer invalid")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("errorCode").value("A0001")); + + perform.andDo(document("tags-invalidToken", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("bearer token") + ), + pathParameters( + parameterWithName("repositoryName").description("레포지토리 이름") + ), + responseFields( + fieldWithPath("errorCode").type(STRING).description("에러 코드") + ) + )); + } + + @DisplayName("유효하지 않은 레포지토리 태그 추출 요청시 404 예외 메시지가 반환된다.") + @Test + void extractLanguageTags_InvalidRepository_ExceptionThrown() throws Exception { + String url = + "/api/github/repositories/{repositoryName}}/tags/languages"; + + given(tagService.extractTags(any(ExtractionRequestDto.class))) + .willThrow(new PlatformHttpErrorException()); + + ResultActions perform = mockMvc.perform(get(url, userName, "invalidrepo") + .header("Authorization", accessToken)) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("errorCode").value("V0001")); + + verify(tagService, times(1)) + .extractTags(any(ExtractionRequestDto.class)); + + perform.andDo(document("tags-invalidRepository", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("bearer token") + ), + pathParameters( + parameterWithName("repositoryName").description("잘못된 레포지토리 이름") + ), + responseFields( + fieldWithPath("errorCode").type(STRING).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 new file mode 100644 index 000000000..5e12ed255 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/user/UserAcceptanceTest.java @@ -0,0 +1,361 @@ +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 new file mode 100644 index 000000000..0c77111cd --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/user/UserFactory.java @@ -0,0 +1,51 @@ +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 new file mode 100644 index 000000000..ef788e809 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/user/application/UserServiceIntegrationTest.java @@ -0,0 +1,226 @@ +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 new file mode 100644 index 000000000..06ba22e70 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/user/application/UserServiceMockTest.java @@ -0,0 +1,202 @@ +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 new file mode 100644 index 000000000..8f9f896fd --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/user/domain/UserRepositoryTest.java @@ -0,0 +1,44 @@ +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 new file mode 100644 index 000000000..3792cbdb6 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/user/domain/UserTest.java @@ -0,0 +1,28 @@ +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 new file mode 100644 index 000000000..dffe389c3 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/user/presentation/UserControllerTest.java @@ -0,0 +1,341 @@ +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/backend/pick-git/src/test/resources/testImage1.png b/backend/pick-git/src/test/resources/testImage1.png new file mode 100644 index 000000000..c8a034157 Binary files /dev/null and b/backend/pick-git/src/test/resources/testImage1.png differ diff --git a/backend/pick-git/src/test/resources/testImage2.png b/backend/pick-git/src/test/resources/testImage2.png new file mode 100644 index 000000000..026ce932b Binary files /dev/null and b/backend/pick-git/src/test/resources/testImage2.png differ diff --git a/frontend/.eslintrc b/frontend/.eslintrc new file mode 100644 index 000000000..96c53617f --- /dev/null +++ b/frontend/.eslintrc @@ -0,0 +1,48 @@ +{ + "env": { + "browser": true, + "es2021": true + }, + "extends": [ + "eslint:recommended", + "plugin:react/recommended", + "plugin:react-hooks/recommended", + "plugin:@typescript-eslint/recommended", + "plugin:import/errors", + "plugin:import/warnings", + "plugin:import/typescript", + "plugin:jsx-a11y/recommended", + "prettier", + "plugin:prettier/recommended", + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaFeatures": { + "jsx": true + }, + "ecmaVersion": 12, + "sourceType": "module" + }, + "plugins": [ + "react", + "@typescript-eslint" + ], + "rules": { + "linebreak-style": [ + "error", + "unix" + ], + "quotes": [ + "error", + "double" + ], + "semi": [ + "error", + "always" + ], + "react/react-in-jsx-scope": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/no-empty-interface": "off", + "prettier/prettier": "error" + } +} \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 000000000..1edb22ea3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.vscode/ +yarn-error.log \ No newline at end of file diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 000000000..a585e36de --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,9 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": false, + "jsxSingleQuote": false, + "printWidth": 120, + "tabWidth": 2, + "endOfLine": "auto" +} diff --git a/frontend/.storybook/main.js b/frontend/.storybook/main.js new file mode 100644 index 000000000..219b9ece6 --- /dev/null +++ b/frontend/.storybook/main.js @@ -0,0 +1,11 @@ +module.exports = { + stories: ["../src/**/*.stories.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"], + addons: ["@storybook/addon-links", "@storybook/addon-essentials"], + webpackFinal: async (config) => { + config.module.rules.unshift({ + test: /\.svg$/, + use: ["@svgr/webpack", "url-loader"], + }); + return config; + }, +}; diff --git a/frontend/.storybook/preview.js b/frontend/.storybook/preview.js new file mode 100644 index 000000000..d4a73af5b --- /dev/null +++ b/frontend/.storybook/preview.js @@ -0,0 +1,43 @@ +import { addDecorator } from '@storybook/react'; +import { MemoryRouter } from 'react-router-dom'; +import { theme, GlobalStyle } from '../src/App.style'; +import { ThemeProvider } from 'styled-components'; +import { QueryClientProvider, QueryClient } from 'react-query'; + +const queryClient = new QueryClient() + +export const parameters = { + actions: { argTypesRegex: "^on[A-Z].*" }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + viewport: { + viewports: { + mobile: { + name: "mobile", + styles: { + width: "375px", + height: "812px", + }, + } + } + }, + layout: 'fullscreen', +} + +addDecorator(Story => ( + + + + + + + + + ) +); + +localStorage.setItem("accessToken", "test"); \ No newline at end of file diff --git a/frontend/.storybook/utils/LoggedInWrapper.tsx b/frontend/.storybook/utils/LoggedInWrapper.tsx new file mode 100644 index 000000000..49ce10bbd --- /dev/null +++ b/frontend/.storybook/utils/LoggedInWrapper.tsx @@ -0,0 +1,21 @@ +import { useContext, useEffect } from "react"; +import UserContext, { UserContextProvider } from "../../src/contexts/UserContext"; + +const LoggedInWrapper = ({ children }: { children: React.ReactElement }) => { + const Inner = () => { + const { login } = useContext(UserContext); + + useEffect(() => login("test", "Tanney"), []); + + return <>; + }; + + return ( + + + {children} + + ); +}; + +export default LoggedInWrapper; \ No newline at end of file diff --git a/frontend/.storybook/utils/components.tsx b/frontend/.storybook/utils/components.tsx new file mode 100644 index 000000000..caed1b703 --- /dev/null +++ b/frontend/.storybook/utils/components.tsx @@ -0,0 +1,7 @@ +import styled from "styled-components"; + +export const TextEditorWrapper = styled.div` + border-radius: 4px; + padding: 1.1rem 1rem; + background-color: #efefef; +`; diff --git a/frontend/babel.config.ts b/frontend/babel.config.ts new file mode 100644 index 000000000..8ca8218d1 --- /dev/null +++ b/frontend/babel.config.ts @@ -0,0 +1,5 @@ +const config = { + presets: ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"], +}; + +export default config; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 000000000..8d44333e1 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,61 @@ +{ + "name": "frontend", + "version": "0.0.0", + "main": "index.js", + "license": "MIT", + "scripts": { + "build": "NODE_ENV=production DEPLOY=main webpack", + "build-dev": "NODE_ENV=development DEPLOY=develop webpack", + "start": "NODE_ENV=development webpack serve", + "storybook": "start-storybook -p 6006", + "build-storybook": "build-storybook", + "mock-serve": "json-server ./src/mocks/db.json --routes ./src/mocks/routes.json --port 3001" + }, + "dependencies": { + "axios": "^0.21.1", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-query": "^3.18.1", + "react-router-dom": "^5.2.0", + "styled-components": "^5.3.0", + "styled-normalize": "^8.0.7" + }, + "devDependencies": { + "@babel/core": "^7.14.6", + "@babel/preset-env": "^7.14.7", + "@babel/preset-react": "^7.14.5", + "@babel/preset-typescript": "^7.14.5", + "@storybook/addon-actions": "^6.3.2", + "@storybook/addon-essentials": "^6.3.2", + "@storybook/addon-links": "^6.3.2", + "@storybook/cli": "^6.3.2", + "@storybook/react": "^6.3.2", + "@svgr/webpack": "^5.5.0", + "@types/node": "^16.0.1", + "@types/react": "^17.0.13", + "@types/react-dom": "^17.0.8", + "@types/react-router-dom": "^5.1.7", + "@types/styled-components": "^5.1.11", + "@typescript-eslint/eslint-plugin": "^4.28.2", + "@typescript-eslint/parser": "^4.28.2", + "babel-loader": "^8.2.2", + "clean-webpack-plugin": "^4.0.0-alpha.0", + "eslint": "^7.30.0", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-import": "^2.23.4", + "eslint-plugin-jsx-a11y": "^6.4.1", + "eslint-plugin-prettier": "^3.4.0", + "eslint-plugin-react": "^7.24.0", + "eslint-plugin-react-hooks": "^4.2.0", + "html-webpack-plugin": "^5.3.2", + "json-server": "^0.16.3", + "prettier": "^2.3.2", + "ts-loader": "^9.2.3", + "ts-node": "^10.0.0", + "typescript": "^4.3.5", + "url-loader": "^4.1.1", + "webpack": "^5.42.0", + "webpack-cli": "^4.7.2", + "webpack-dev-server": "^3.11.2" + } +} diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 000000000..7f4f11483 --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,16 @@ + + + + + + + 깃-들다 PickGit + + + +
+ + diff --git a/frontend/script/deploy_script.sh b/frontend/script/deploy_script.sh new file mode 100644 index 000000000..42aeaff9f --- /dev/null +++ b/frontend/script/deploy_script.sh @@ -0,0 +1,68 @@ +#!/bin/bash + +if [ $# -eq 0 ] +then + echo "Usage: auto_build [options]" + echo " Options" + echo " -c (String) certificate" + echo " -p (int) port (default 22)" + echo " -h (String) host" + echo " -l (String) location (defualt /home/ubuntu))" + echo " -u (String) user (defualt ubuntu)" + exit 1 +fi + +CERTIFICATE_PATH="" +PORT=22 +HOST="" +USER="ubuntu" +LOCATION="/home/ubuntu" + +#parse options +while (( "$#" )); do + case "$1" in + -c|--certificate) + if [ -n "$2" ] && [ ${2:0:1} != "-" ]; then + CERTIFICATE_PATH=$2 + shift 2 + fi + ;; + -p|--port) + if [ -n "$2" ] && [ ${2:0:1} != "-" ]; then + PORT=$2 + shift 2 + fi + ;; + -h|--host) + if [ -n "$2" ] && [ ${2:0:1} != "-" ]; then + HOST=$2 + shift 2 + fi + ;; + -l|--location) + if [ -n "$2" ] && [ ${2:0:1} != "-" ]; then + LOCATION=$2 + shift 2 + fi + ;; + -u|--user) + if [ -n "$2" ] && [ ${2:0:1} != "-" ]; then + USER=$2 + fi + ;; + esac +done + +if [ ! -f "$CERTIFICATE_PATH" ]; then + echo "Error: certificate is not exist" + exit 1 +fi + +if [ -z $HOST ]; then + echo "Error: host is required" + exit 1 +fi + +scp -i $CERTIFICATE_PATH -r ../dist $USER@$HOST:$LOCATION + +echo "deploy finished" diff --git a/frontend/src/@types/assets.d.ts b/frontend/src/@types/assets.d.ts new file mode 100644 index 000000000..892a70456 --- /dev/null +++ b/frontend/src/@types/assets.d.ts @@ -0,0 +1,5 @@ +declare module "*.svg"; +declare module "*.png"; +declare module "*.jpg"; +declare module "*.tiff"; +declare module "*.jpeg"; diff --git a/frontend/src/@types/index.ts b/frontend/src/@types/index.ts new file mode 100644 index 000000000..a42993257 --- /dev/null +++ b/frontend/src/@types/index.ts @@ -0,0 +1,63 @@ +export interface ProfileData { + name: string; + image: string; + description: string; + followerCount: number; + followingCount: number; + postCount: number; + githubUrl: string; + company: string; + location: string; + website: string; + twitter: string; + following?: boolean; +} + +export interface CommentData { + commentId: string; + authorName: string; + content: string; + isLiked: boolean; +} + +export interface CommentAddData { + postId: Post["postId"]; + commentContent: CommentData["content"]; +} + +export interface Post { + postId: string; + imageUrls: string[]; + githubRepoUrl: string; + content: string; + authorName: string; + profileImageUrl: string; + likesCount: number; + tags: []; + createdAt: string; + updatedAt: string; + comments: CommentData[]; + isLiked: boolean; +} + +export interface PostAddFormData { + files: File[]; + githubRepositoryName: string; + tags: string[]; + content: string; +} + +export interface GithubStats { + stars: string; + commits: string; + prs: string; + issues: string; + contributes: string; +} + +export interface GithubRepository { + url: string; + name: string; +} + +export type Tags = string[]; diff --git a/frontend/src/@types/react-app-env.d.ts b/frontend/src/@types/react-app-env.d.ts new file mode 100644 index 000000000..f8d29d8e3 --- /dev/null +++ b/frontend/src/@types/react-app-env.d.ts @@ -0,0 +1,12 @@ +/// +/// +/// + +import "styled-components"; +import { theme } from "../App.style"; + +type StyledTheme = typeof theme; + +declare module "styled-components" { + export interface DefaultTheme extends StyledTheme {} +} diff --git a/frontend/src/App.style.ts b/frontend/src/App.style.ts new file mode 100644 index 000000000..373e028d1 --- /dev/null +++ b/frontend/src/App.style.ts @@ -0,0 +1,96 @@ +import { createGlobalStyle } from "styled-components"; +import normalize from "styled-normalize"; + +export const theme = { + color: { + appBackgroundColor: "#ffffff", + primaryColor: "#fc3465", + darkerSecondaryColor: "#ffdede", + secondaryColor: "#fff0f0", + tertiaryColor: "#6d6d6d", + textColor: "#3d3d3d", + lighterTextColor: "#959595", + borderColor: "#cfcfcf", + darkBorderColor: "#9f9f9f", + tagItemColor: "#ffc1c1", + separatorColor: "#a4a4a45b", + white: "#ffffff", + }, +}; + +export const GlobalStyle = createGlobalStyle` + ${normalize} + + html, + body, + #root { + margin: 0; + padding: 0; + height: 100%; + font-size: 16px; + font-family: 'Noto Sans KR', sans-serif; + } + + body { + background-color: #efefef; + } + + svg { + display: block; + } + + button { + border: none; + background-color: transparent; + outline: none; + padding: 0px; + cursor: pointer; + } + + ul { + list-style: none; + padding: 0; + margin: 0; + } + + input[type='number']::-webkit-outer-spin-button, + input[type='number']::-webkit-inner-spin-button { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + } + + textarea { + resize: none; + } + + a { + font-weight: bold; + font-size: 2rem; + display: block; + margin: 20px; + } + a { + all: unset; + } + a:link { + text-decoration: none; + color: #3f464d; + } + a:visited { + text-decoration: none; + color: #3f464d; + } + a:active { + text-decoration: none; + color: #3f464d; + } + a:hover { + text-decoration: none; + cursor: pointer; + } + + * { + box-sizing: border-box; + } +`; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 000000000..9e64c3433 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,58 @@ +import { BrowserRouter, Redirect, Route, Switch } from "react-router-dom"; + +import { PAGE_URL } from "./constants/urls"; +import LoginPage from "./pages/LoginPage/LoginPage"; +import NavigationHeader from "./components/@layout/NavigationHeader/NavigationHeader"; +import HomeFeedPage from "./pages/HomeFeedPage/HomeFeedPage"; +import ProfilePage from "./pages/ProfilePage/ProfilePage"; +import AuthLoginProcessingPage from "./pages/AuthLoginProcessingPage/AuthLoginProcessingPage"; +import PostAddStepHeader from "./components/PostAddStepHeader/PostAddStepHeader"; +import AddPostPage from "./pages/AddPostPage/AddPostPage"; +import { PostAddDataContextProvider } from "./contexts/PostAddDataContext"; +import UserFeedPage from "./pages/UserFeedPage/UserFeedPage"; +import TagFeedPage from "./pages/TagFeedPage/TagFeedPage"; + +const App = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; +export default App; diff --git a/frontend/src/assets/icons/add-box.svg b/frontend/src/assets/icons/add-box.svg new file mode 100644 index 000000000..cdedcb32b --- /dev/null +++ b/frontend/src/assets/icons/add-box.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/src/assets/icons/book.svg b/frontend/src/assets/icons/book.svg new file mode 100644 index 000000000..bb1971142 --- /dev/null +++ b/frontend/src/assets/icons/book.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/icons/briefcase-solid.svg b/frontend/src/assets/icons/briefcase-solid.svg new file mode 100644 index 000000000..5902367e3 --- /dev/null +++ b/frontend/src/assets/icons/briefcase-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/cancel.svg b/frontend/src/assets/icons/cancel.svg new file mode 100644 index 000000000..87ab5f494 --- /dev/null +++ b/frontend/src/assets/icons/cancel.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/src/assets/icons/clock.svg b/frontend/src/assets/icons/clock.svg new file mode 100644 index 000000000..3d2d80814 --- /dev/null +++ b/frontend/src/assets/icons/clock.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/assets/icons/edit.svg b/frontend/src/assets/icons/edit.svg new file mode 100644 index 000000000..df35c78d1 --- /dev/null +++ b/frontend/src/assets/icons/edit.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/assets/icons/github-brands.svg b/frontend/src/assets/icons/github-brands.svg new file mode 100644 index 000000000..be042b787 --- /dev/null +++ b/frontend/src/assets/icons/github-brands.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/github-large.svg b/frontend/src/assets/icons/github-large.svg new file mode 100644 index 000000000..7e12313a5 --- /dev/null +++ b/frontend/src/assets/icons/github-large.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/assets/icons/github-repository.svg b/frontend/src/assets/icons/github-repository.svg new file mode 100644 index 000000000..21b893713 --- /dev/null +++ b/frontend/src/assets/icons/github-repository.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/assets/icons/github.svg b/frontend/src/assets/icons/github.svg new file mode 100644 index 000000000..0beb6fd2f --- /dev/null +++ b/frontend/src/assets/icons/github.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/src/assets/icons/go-back.svg b/frontend/src/assets/icons/go-back.svg new file mode 100644 index 000000000..e4b9db4a3 --- /dev/null +++ b/frontend/src/assets/icons/go-back.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/src/assets/icons/go-forward.svg b/frontend/src/assets/icons/go-forward.svg new file mode 100644 index 000000000..59ae41786 --- /dev/null +++ b/frontend/src/assets/icons/go-forward.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/src/assets/icons/heart-line.svg b/frontend/src/assets/icons/heart-line.svg new file mode 100644 index 000000000..339103154 --- /dev/null +++ b/frontend/src/assets/icons/heart-line.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/assets/icons/heart.svg b/frontend/src/assets/icons/heart.svg new file mode 100644 index 000000000..3126dc8dd --- /dev/null +++ b/frontend/src/assets/icons/heart.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/assets/icons/home.svg b/frontend/src/assets/icons/home.svg new file mode 100644 index 000000000..7f93dd994 --- /dev/null +++ b/frontend/src/assets/icons/home.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/assets/icons/index.tsx b/frontend/src/assets/icons/index.tsx new file mode 100644 index 000000000..825f20079 --- /dev/null +++ b/frontend/src/assets/icons/index.tsx @@ -0,0 +1,27 @@ +export { ReactComponent as HomeIcon } from "./home.svg"; +export { ReactComponent as PersonIcon } from "./person.svg"; +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 GoBackIcon } from "./go-back.svg"; +export { ReactComponent as GoForwardIcon } from "./go-forward.svg"; +export { ReactComponent as HeartLineIcon } from "./heart-line.svg"; +export { ReactComponent as HeartIcon } from "./heart.svg"; +export { ReactComponent as PostHeartLineIcon } from "./post-heart-line.svg"; +export { ReactComponent as PostHeartIcon } from "./post-heart.svg"; +export { ReactComponent as EditIcon } from "./edit.svg"; +export { ReactComponent as GithubIcon } from "./github.svg"; +export { ReactComponent as GithubLargeIcon } from "./github-large.svg"; +export { ReactComponent as SendIcon } from "./send.svg"; +export { ReactComponent as CompanyIcon } from "./briefcase-solid.svg"; +export { ReactComponent as GithubDarkIcon } from "./github-brands.svg"; +export { ReactComponent as LocationIcon } from "./map-marker-alt-solid.svg"; +export { ReactComponent as WebsiteLinkIcon } from "./link-solid.svg"; +export { ReactComponent as TwitterIcon } from "./twitter-brands.svg"; +export { ReactComponent as StarIcon } from "./star.svg"; +export { ReactComponent as ClockIcon } from "./clock.svg"; +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"; diff --git a/frontend/src/assets/icons/issue.svg b/frontend/src/assets/icons/issue.svg new file mode 100644 index 000000000..9d009eb05 --- /dev/null +++ b/frontend/src/assets/icons/issue.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/icons/link-solid.svg b/frontend/src/assets/icons/link-solid.svg new file mode 100644 index 000000000..d8698de2f --- /dev/null +++ b/frontend/src/assets/icons/link-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/login.svg b/frontend/src/assets/icons/login.svg new file mode 100644 index 000000000..91a2e2faa --- /dev/null +++ b/frontend/src/assets/icons/login.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/assets/icons/map-marker-alt-solid.svg b/frontend/src/assets/icons/map-marker-alt-solid.svg new file mode 100644 index 000000000..6e208c0eb --- /dev/null +++ b/frontend/src/assets/icons/map-marker-alt-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/person.svg b/frontend/src/assets/icons/person.svg new file mode 100644 index 000000000..669900b93 --- /dev/null +++ b/frontend/src/assets/icons/person.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/assets/icons/post-heart-line.svg b/frontend/src/assets/icons/post-heart-line.svg new file mode 100644 index 000000000..71cfe4770 --- /dev/null +++ b/frontend/src/assets/icons/post-heart-line.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/assets/icons/post-heart.svg b/frontend/src/assets/icons/post-heart.svg new file mode 100644 index 000000000..58b4afe69 --- /dev/null +++ b/frontend/src/assets/icons/post-heart.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/assets/icons/pr.svg b/frontend/src/assets/icons/pr.svg new file mode 100644 index 000000000..e8afa99c3 --- /dev/null +++ b/frontend/src/assets/icons/pr.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/src/assets/icons/search.svg b/frontend/src/assets/icons/search.svg new file mode 100644 index 000000000..077884700 --- /dev/null +++ b/frontend/src/assets/icons/search.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/assets/icons/send.svg b/frontend/src/assets/icons/send.svg new file mode 100644 index 000000000..4d6061d52 --- /dev/null +++ b/frontend/src/assets/icons/send.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/assets/icons/star.svg b/frontend/src/assets/icons/star.svg new file mode 100644 index 000000000..97ca44286 --- /dev/null +++ b/frontend/src/assets/icons/star.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/assets/icons/twitter-brands.svg b/frontend/src/assets/icons/twitter-brands.svg new file mode 100644 index 000000000..6249e6870 --- /dev/null +++ b/frontend/src/assets/icons/twitter-brands.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/images/default-image.png b/frontend/src/assets/images/default-image.png new file mode 100644 index 000000000..386f37c05 Binary files /dev/null and b/frontend/src/assets/images/default-image.png differ diff --git a/frontend/src/assets/images/default-profile.png b/frontend/src/assets/images/default-profile.png new file mode 100644 index 000000000..45f591bd7 Binary files /dev/null and b/frontend/src/assets/images/default-profile.png differ diff --git a/frontend/src/components/@layout/NavigationHeader/NavigationHeader.stories.tsx b/frontend/src/components/@layout/NavigationHeader/NavigationHeader.stories.tsx new file mode 100644 index 000000000..520b2dc27 --- /dev/null +++ b/frontend/src/components/@layout/NavigationHeader/NavigationHeader.stories.tsx @@ -0,0 +1,22 @@ +import { Story } from "@storybook/react"; +import LoggedInWrapper from "../../../../.storybook/utils/LoggedInWrapper"; + +import NavigationHeader from "./NavigationHeader"; + +export default { + title: "Components/Layout/NavigationHeader", + component: NavigationHeader, +}; + +const DefaultTemplate: Story = (args) => ; +const LoggedInTemplate: Story = (args) => ( + + + +); + +export const Default = DefaultTemplate.bind({}); +Default.args = {}; + +export const LoggedIn = LoggedInTemplate.bind({}); +LoggedIn.args = {}; diff --git a/frontend/src/components/@layout/NavigationHeader/NavigationHeader.style.ts b/frontend/src/components/@layout/NavigationHeader/NavigationHeader.style.ts new file mode 100644 index 000000000..42371b79f --- /dev/null +++ b/frontend/src/components/@layout/NavigationHeader/NavigationHeader.style.ts @@ -0,0 +1,35 @@ +import { Link } from "react-router-dom"; +import styled from "styled-components"; +import { Header } from "../../@styled/layout"; + +export const Container = styled(Header)` + display: flex; + justify-content: space-between; + padding: 1.0625rem 1.375rem; +`; + +export const HomeLink = styled(Link)``; + +export const Navigation = styled.nav` + display: flex; +`; + +export const NavigationItem = styled(Link)` + transition: opacity 0.5s; + + :not(:last-child) { + margin-right: 1.1875rem; + } + + :hover { + opacity: 0.5; + } +`; + +export const FlexWrapper = styled.div` + display: flex; + + button { + margin-left: 1.5rem; + } +`; diff --git a/frontend/src/components/@layout/NavigationHeader/NavigationHeader.tsx b/frontend/src/components/@layout/NavigationHeader/NavigationHeader.tsx new file mode 100644 index 000000000..589adae06 --- /dev/null +++ b/frontend/src/components/@layout/NavigationHeader/NavigationHeader.tsx @@ -0,0 +1,58 @@ +import { Container, Navigation, HomeLink, NavigationItem, FlexWrapper } from "./NavigationHeader.style"; +import { AddBoxIcon, HomeIcon, LoginIcon, PersonIcon, SearchIcon } from "../../../assets/icons"; +import { PAGE_URL } from "../../../constants/urls"; +import { useContext } from "react"; +import UserContext from "../../../contexts/UserContext"; +import Button from "../../@shared/Button/Button"; + +const NavigationHeader = () => { + const { isLoggedIn, logout } = useContext(UserContext); + + const handleLogoutButtonClick = () => logout(); + + const UnAuthenticatedNavigation = () => ( + + + + + + + + + ); + + const AuthenticatedNavigation = () => ( + + + + + + + + + + + + + + + ); + + return ( + + 깃들다 + {isLoggedIn ? ( + + + + + ) : ( + + )} + + ); +}; + +export default NavigationHeader; diff --git a/frontend/src/components/@layout/PageLoading/PageLoading.stories.tsx b/frontend/src/components/@layout/PageLoading/PageLoading.stories.tsx new file mode 100644 index 000000000..ecf12ff10 --- /dev/null +++ b/frontend/src/components/@layout/PageLoading/PageLoading.stories.tsx @@ -0,0 +1,13 @@ +import { Story } from "@storybook/react"; + +import PageLoading, { Props } from "./PageLoading"; + +export default { + title: "Components/Shared/PageLoading", + component: PageLoading, +}; + +const Template: Story = (args) => 깃들다; + +export const Default = Template.bind({}); +Default.args = {}; diff --git a/frontend/src/components/@layout/PageLoading/PageLoading.style.ts b/frontend/src/components/@layout/PageLoading/PageLoading.style.ts new file mode 100644 index 000000000..986ef4669 --- /dev/null +++ b/frontend/src/components/@layout/PageLoading/PageLoading.style.ts @@ -0,0 +1,9 @@ +import styled from "styled-components"; + +export const Container = styled.span` + display: flex; + width: 100%; + height: 100%; + justify-content: center; + align-items: center; +`; diff --git a/frontend/src/components/@layout/PageLoading/PageLoading.tsx b/frontend/src/components/@layout/PageLoading/PageLoading.tsx new file mode 100644 index 000000000..321f783ff --- /dev/null +++ b/frontend/src/components/@layout/PageLoading/PageLoading.tsx @@ -0,0 +1,17 @@ +import Loader, { Props as LoaderProps } from "../../@shared/Loader/Loader"; +import { Container } from "./PageLoading.style"; + +export interface Props extends React.HTMLAttributes { + LoaderSize?: string; + LoaderKind?: LoaderProps["kind"]; +} + +const PageLoading = ({ LoaderSize = "2rem", LoaderKind = "spinner", ...props }: Props) => { + return ( + + + + ); +}; + +export default PageLoading; diff --git a/frontend/src/components/@layout/SearchHeader/SearchHeader.stories.tsx b/frontend/src/components/@layout/SearchHeader/SearchHeader.stories.tsx new file mode 100644 index 000000000..675863d8b --- /dev/null +++ b/frontend/src/components/@layout/SearchHeader/SearchHeader.stories.tsx @@ -0,0 +1,13 @@ +import { Story } from "@storybook/react"; + +import SearchHeader, { Props } from "./SearchHeader"; + +export default { + title: "Components/Layout/SearchHeader", + component: SearchHeader, +}; + +const Template: Story = (args) => ; + +export const Default = Template.bind({}); +Default.args = {}; diff --git a/frontend/src/components/@layout/SearchHeader/SearchHeader.style.ts b/frontend/src/components/@layout/SearchHeader/SearchHeader.style.ts new file mode 100644 index 000000000..5ac5eff3d --- /dev/null +++ b/frontend/src/components/@layout/SearchHeader/SearchHeader.style.ts @@ -0,0 +1,22 @@ +import styled from "styled-components"; +import { Header } from "../../@styled/layout"; + +export const Container = styled(Header)` + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.625rem 1.4375rem; +`; + +export const GoBackLink = styled.a` + transition: opacity 0.5s; + margin-right: 0.9375rem; + + :hover { + opacity: 0.5; + } +`; + +export const SearchInputWrapper = styled.div` + flex-grow: 1; +`; diff --git a/frontend/src/components/@layout/SearchHeader/SearchHeader.tsx b/frontend/src/components/@layout/SearchHeader/SearchHeader.tsx new file mode 100644 index 000000000..b73a8fbda --- /dev/null +++ b/frontend/src/components/@layout/SearchHeader/SearchHeader.tsx @@ -0,0 +1,27 @@ +import Input from "../../@shared/Input/Input"; +import { Container, GoBackLink, SearchInputWrapper } from "./SearchHeader.style"; +import { GoBackIcon } from "../../../assets/icons"; +import { useHistory } from "react-router-dom"; + +export interface Props extends React.HTMLAttributes {} + +const SearchHeader = ({}: Props) => { + const history = useHistory(); + + const handleGoBack = () => { + history.goBack(); + }; + + return ( + + + + + + + + + ); +}; + +export default SearchHeader; diff --git a/frontend/src/components/@layout/StepHeader/StepHeader.stories.tsx b/frontend/src/components/@layout/StepHeader/StepHeader.stories.tsx new file mode 100644 index 000000000..d81e92728 --- /dev/null +++ b/frontend/src/components/@layout/StepHeader/StepHeader.stories.tsx @@ -0,0 +1,13 @@ +import { Story } from "@storybook/react"; + +import StepHeader, { Props } from "./StepHeader"; + +export default { + title: "Components/Layout/StepHeader", + component: StepHeader, +}; + +const Template: Story = (args) => Git 리포지터리; + +export const Default = Template.bind({}); +Default.args = {}; diff --git a/frontend/src/components/@layout/StepHeader/StepHeader.style.ts b/frontend/src/components/@layout/StepHeader/StepHeader.style.ts new file mode 100644 index 000000000..1acb22ff2 --- /dev/null +++ b/frontend/src/components/@layout/StepHeader/StepHeader.style.ts @@ -0,0 +1,25 @@ +import styled from "styled-components"; +import { Header } from "../../@styled/layout"; + +export const Container = styled(Header)` + display: flex; + justify-content: space-between; + padding: 1.125rem 1.5rem; +`; + +export const Content = styled.div` + font-size: 1.0625rem; +`; + +export const StepLink = styled.a` + transition: opacity 0.5s; + + :hover { + opacity: 0.5; + } +`; + +export const EmptySpace = styled.div` + display: inline-block; + width: 1.375rem; +`; diff --git a/frontend/src/components/@layout/StepHeader/StepHeader.tsx b/frontend/src/components/@layout/StepHeader/StepHeader.tsx new file mode 100644 index 000000000..6e5912740 --- /dev/null +++ b/frontend/src/components/@layout/StepHeader/StepHeader.tsx @@ -0,0 +1,29 @@ +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; + onNextStepClick?: () => void; + onGoBack: () => void; +} + +const StepHeader = ({ isNextStepExist, children, onGoBack, onNextStepClick }: Props) => { + return ( + + + + + {children} + {isNextStepExist ? ( + + + + ) : ( + + )} + + ); +}; + +export default StepHeader; diff --git a/frontend/src/components/@shared/Avatar/Avatar.stories.tsx b/frontend/src/components/@shared/Avatar/Avatar.stories.tsx new file mode 100644 index 000000000..a8295d1e1 --- /dev/null +++ b/frontend/src/components/@shared/Avatar/Avatar.stories.tsx @@ -0,0 +1,31 @@ +import { Story } from "@storybook/react"; + +import Avatar, { Props } from "./Avatar"; + +export default { + title: "Components/Shared/Avatar", + component: Avatar, +}; + +const Template: Story = (args) => ; + +export const Default = Template.bind({}); +Default.args = { + diameter: "3rem", + fontSize: "0.75rem", + imageUrl: + "https://images.unsplash.com/photo-1518574095400-c75c9b094daa?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=934&q=80", + name: "깃들다계정", +}; + +export const NoName = Template.bind({}); +NoName.args = { + diameter: "3rem", + imageUrl: + "https://images.unsplash.com/photo-1518574095400-c75c9b094daa?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=934&q=80", +}; + +export const NoProfileImage = Template.bind({}); +NoProfileImage.args = { + diameter: "3rem", +}; diff --git a/frontend/src/components/@shared/Avatar/Avatar.style.ts b/frontend/src/components/@shared/Avatar/Avatar.style.ts new file mode 100644 index 000000000..e031e06ea --- /dev/null +++ b/frontend/src/components/@shared/Avatar/Avatar.style.ts @@ -0,0 +1,30 @@ +import styled from "styled-components"; + +import defaultProfile from "../../../assets/images/default-profile.png"; + +export const Container = styled.div` + display: inline-flex; + flex-direction: column; + justify-content: space-between; + align-items: center; +`; + +export const CircleImage = styled.div` + ${({ width, height, backgroundImage }) => ` + width: ${width}; + height: ${height}; + background: no-repeat center/cover url(${backgroundImage ?? defaultProfile}); + `} + + border-radius: 50%; +`; + +export const Name = styled.div` + ${({ fontSize, theme }) => ` + font-size: ${fontSize ?? "1rem"}; + color: ${theme.color.textColor}; + `} + + margin-top: 0.375rem; + font-weight: bold; +`; diff --git a/frontend/src/components/@shared/Avatar/Avatar.tsx b/frontend/src/components/@shared/Avatar/Avatar.tsx new file mode 100644 index 000000000..9a2df4c36 --- /dev/null +++ b/frontend/src/components/@shared/Avatar/Avatar.tsx @@ -0,0 +1,17 @@ +import { CircleImage, Container, Name } from "./Avatar.style"; + +export interface Props { + diameter: string; + fontSize?: string; + imageUrl?: string; + name?: string; +} + +const Avatar = ({ diameter, fontSize, imageUrl, name }: Props) => ( + + + {name && {name}} + +); + +export default Avatar; diff --git a/frontend/src/components/@shared/Button/Button.stories.tsx b/frontend/src/components/@shared/Button/Button.stories.tsx new file mode 100644 index 000000000..aee5df4fd --- /dev/null +++ b/frontend/src/components/@shared/Button/Button.stories.tsx @@ -0,0 +1,37 @@ +import { Story } from "@storybook/react"; + +import Button, { Props } from "./Button"; + +export default { + title: "Components/Shared/Button", + component: Button, +}; + +const Template: Story = (args) => ; + +export const Default = Template.bind({}); +Default.args = {}; + +export const BaemintSquaredInline = Template.bind({}); +BaemintSquaredInline.args = { + kind: "squaredInline", + backgroundColor: "#40b7b2", +}; + +export const BaemintSquaredBlock = Template.bind({}); +BaemintSquaredBlock.args = { + kind: "squaredBlock", + backgroundColor: "#40b7b2", +}; + +export const BaemintRoundedInline = Template.bind({}); +BaemintRoundedInline.args = { + kind: "roundedInline", + backgroundColor: "#40b7b2", +}; + +export const BaemintRoundedBlock = Template.bind({}); +BaemintRoundedBlock.args = { + kind: "roundedBlock", + backgroundColor: "#40b7b2", +}; diff --git a/frontend/src/components/@shared/Button/Button.style.ts b/frontend/src/components/@shared/Button/Button.style.ts new file mode 100644 index 000000000..cf6a8e9b2 --- /dev/null +++ b/frontend/src/components/@shared/Button/Button.style.ts @@ -0,0 +1,44 @@ +import styled from "styled-components"; + +const Button = styled.button` + ${({ theme, backgroundColor, color }) => ` + color: ${color ?? theme.color.white}; + background-color: ${backgroundColor ?? theme.color.primaryColor}; + `} + + text-align: center; + transition: opacity 0.5s; + + :hover { + opacity: 0.5; + } +`; + +const InlineButton = styled(Button)` + display: inline-block; + padding: 0.4375rem 0.875rem; + font-size: 0.75rem; +`; + +const BlockButton = styled(Button)` + display: block; + width: 100%; + padding: 0.5rem; + font-size: 1rem; +`; + +export const SquaredInlineButton = styled(InlineButton)` + border-radius: 4px; +`; + +export const SquaredBlockButton = styled(BlockButton)` + border-radius: 4px; +`; + +export const RoundedInlineButton = styled(InlineButton)` + border-radius: 24px; +`; + +export const RoundedBlockButton = styled(BlockButton)` + border-radius: 24px; +`; diff --git a/frontend/src/components/@shared/Button/Button.tsx b/frontend/src/components/@shared/Button/Button.tsx new file mode 100644 index 000000000..06aa46d04 --- /dev/null +++ b/frontend/src/components/@shared/Button/Button.tsx @@ -0,0 +1,20 @@ +import { SquaredInlineButton, SquaredBlockButton, RoundedInlineButton, RoundedBlockButton } from "./Button.style"; + +export interface Props extends React.ButtonHTMLAttributes { + kind?: "squaredInline" | "squaredBlock" | "roundedInline" | "roundedBlock"; + backgroundColor?: string; + color?: string; +} + +const Button = ({ kind = "squaredInline", ...props }: Props) => { + const button = { + squaredInline: , + squaredBlock: , + roundedInline: , + roundedBlock: , + }; + + return button[kind]; +}; + +export default Button; diff --git a/frontend/src/components/@shared/Chip/Chip.stories.tsx b/frontend/src/components/@shared/Chip/Chip.stories.tsx new file mode 100644 index 000000000..9c651a58d --- /dev/null +++ b/frontend/src/components/@shared/Chip/Chip.stories.tsx @@ -0,0 +1,19 @@ +import { Story } from "@storybook/react"; + +import Chip, { Props } from "./Chip"; + +export default { + title: "Components/Shared/Chip", + component: Chip, +}; + +const Template: Story = (args) => 깃들다; + +export const Default = Template.bind({}); +Default.args = {}; + +export const Deletable = Template.bind({}); + +Deletable.args = { + onDelete: () => alert("삭제됨!"), +}; diff --git a/frontend/src/components/@shared/Chip/Chip.style.ts b/frontend/src/components/@shared/Chip/Chip.style.ts new file mode 100644 index 000000000..58f4ce948 --- /dev/null +++ b/frontend/src/components/@shared/Chip/Chip.style.ts @@ -0,0 +1,25 @@ +import styled from "styled-components"; + +export const Container = styled.span` + ${({ backgroundColor, theme }) => ` + background-color: ${backgroundColor ?? theme.color.tagItemColor}; + color: ${theme.color.white}; + `}; + padding: 0.4375rem 0.9375rem; + border-radius: 24px; + display: inline-flex; + align-items: center; +`; + +export const Text = styled.span` + display: inline-flex; + align-items: center; + font-size: 0.875rem; + font-weight: bold; +`; + +export const DeleteButton = styled.button` + margin-left: 0.625rem; + display: inline-flex; + align-items: center; +`; diff --git a/frontend/src/components/@shared/Chip/Chip.tsx b/frontend/src/components/@shared/Chip/Chip.tsx new file mode 100644 index 000000000..cc7654f7b --- /dev/null +++ b/frontend/src/components/@shared/Chip/Chip.tsx @@ -0,0 +1,23 @@ +import { CancelIcon } from "../../../assets/icons"; +import { Container, DeleteButton, Text } from "./Chip.style"; + +export interface Props extends React.HTMLAttributes { + backgroundColor?: string; + onDelete?: React.MouseEventHandler; + children: React.ReactText; +} + +const Chip = ({ backgroundColor, children, onDelete }: Props) => { + return ( + + {children} + {onDelete && ( + + + + )} + + ); +}; + +export default Chip; diff --git a/frontend/src/components/@shared/CircleIcon/CircleIcon.stories.tsx b/frontend/src/components/@shared/CircleIcon/CircleIcon.stories.tsx new file mode 100644 index 000000000..a3cbdf630 --- /dev/null +++ b/frontend/src/components/@shared/CircleIcon/CircleIcon.stories.tsx @@ -0,0 +1,16 @@ +import { Story } from "@storybook/react"; + +import CircleIcon, { Props } from "./CircleIcon"; + +export default { + title: "Components/Shared/CircleIcon", + component: CircleIcon, +}; + +const Template: Story = (args) => Git; + +export const Default = Template.bind({}); +Default.args = { + diameter: "3rem", + name: "깃들다 들다", +}; diff --git a/frontend/src/components/@shared/CircleIcon/CircleIcon.style.ts b/frontend/src/components/@shared/CircleIcon/CircleIcon.style.ts new file mode 100644 index 000000000..02af6beb4 --- /dev/null +++ b/frontend/src/components/@shared/CircleIcon/CircleIcon.style.ts @@ -0,0 +1,32 @@ +import styled from "styled-components"; + +export const Container = styled.div` + display: inline-flex; + flex-direction: column; + justify-content: space-between; + align-items: center; +`; + +export const CircleBackground = styled.div` + ${({ width, height, backgroundColor, theme }) => ` + width: ${width}; + height: ${height}; + background-color: ${backgroundColor ?? theme.color.secondaryColor}; + `} + + display: flex; + justify-content: center; + align-items: center; + + border-radius: 50%; +`; + +export const Name = styled.div` + ${({ fontSize, theme }) => ` + font-size: ${fontSize ?? "0.8rem"}; + color: ${theme.color.textColor}; + `} + + margin-top: 0.375rem; + font-weight: 300; +`; diff --git a/frontend/src/components/@shared/CircleIcon/CircleIcon.tsx b/frontend/src/components/@shared/CircleIcon/CircleIcon.tsx new file mode 100644 index 000000000..a06268a6f --- /dev/null +++ b/frontend/src/components/@shared/CircleIcon/CircleIcon.tsx @@ -0,0 +1,19 @@ +import { CircleBackground, Container, Name } from "./CircleIcon.style"; + +export interface Props extends React.HTMLAttributes { + diameter: string; + fontSize?: string; + backgroundColor?: string; + name?: string; +} + +const CircleIcon = ({ diameter, fontSize, backgroundColor, name, children }: Props) => ( + + + {children} + + {name && {name}} + +); + +export default CircleIcon; diff --git a/frontend/src/components/@shared/Comment/Comment.stories.tsx b/frontend/src/components/@shared/Comment/Comment.stories.tsx new file mode 100644 index 000000000..425755a12 --- /dev/null +++ b/frontend/src/components/@shared/Comment/Comment.stories.tsx @@ -0,0 +1,17 @@ +import { Story } from "@storybook/react"; + +import Comment, { Props } from "./Comment"; + +export default { + title: "Components/Shared/Comment", + component: Comment, +}; + +const Template: Story = (args) => ; + +export const Default = Template.bind({}); +Default.args = { + authorName: "Tanney", + content: "개발 너무 재미있어 미치겠어", + isLiked: false, +}; diff --git a/frontend/src/components/@shared/Comment/Comment.style.ts b/frontend/src/components/@shared/Comment/Comment.style.ts new file mode 100644 index 000000000..77261660d --- /dev/null +++ b/frontend/src/components/@shared/Comment/Comment.style.ts @@ -0,0 +1,27 @@ +import { Link } from "react-router-dom"; +import styled from "styled-components"; + +export const Container = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const AuthorName = styled(Link)` + font-weight: bold; + font-size: 0.75rem; + margin-right: 0.75rem; +`; + +export const Content = styled.span` + font-size: 0.625rem; +`; + +export const LikeIconWrapper = styled.button` + padding-top: 2px; + transition: opacity 0.5s; + + :hover { + opacity: 0.7; + } +`; diff --git a/frontend/src/components/@shared/Comment/Comment.tsx b/frontend/src/components/@shared/Comment/Comment.tsx new file mode 100644 index 000000000..5ae0d7fe0 --- /dev/null +++ b/frontend/src/components/@shared/Comment/Comment.tsx @@ -0,0 +1,24 @@ +import { Container, AuthorName, Content, LikeIconWrapper } from "./Comment.style"; +import { HeartIcon, HeartLineIcon } from "../../../assets/icons"; + +export interface Props { + authorName: string; + content: string; + isLiked: boolean; + link?: string; + onCommentLike: () => void; +} + +const Comment = ({ authorName, link, content, isLiked, onCommentLike }: Props) => { + return ( + +
+ {authorName} + {content} +
+ {isLiked ? : } +
+ ); +}; + +export default Comment; diff --git a/frontend/src/components/@shared/ContributionGraph/ContributionGraph.stories.tsx b/frontend/src/components/@shared/ContributionGraph/ContributionGraph.stories.tsx new file mode 100644 index 000000000..f417fdb76 --- /dev/null +++ b/frontend/src/components/@shared/ContributionGraph/ContributionGraph.stories.tsx @@ -0,0 +1,84 @@ +import { Story } from "@storybook/react"; + +import ContributionGraph, { Props } from "./ContributionGraph"; + +export default { + title: "Components/Shared/ContributionGraph", + component: ContributionGraph, +}; + +const Template: Story = (args) => ( +
+ +
+); + +export const Default = Template.bind({}); +Default.args = { + contributionItems: [ + "dense", + "empty", + "normal", + "dense", + "empty", + "normal", + "dense", + "empty", + "normal", + "dense", + "empty", + "normal", + "dense", + "dense", + "dense", + "dense", + "empty", + "normal", + "normal", + "normal", + "normal", + "dense", + "empty", + "normal", + "dense", + "empty", + "normal", + "dense", + "empty", + "normal", + "dense", + "empty", + "normal", + "dense", + "dense", + "dense", + "dense", + "empty", + "normal", + "normal", + "normal", + "normal", + "dense", + "empty", + "normal", + "dense", + "empty", + "normal", + "dense", + "empty", + "normal", + "dense", + "empty", + "normal", + "dense", + "dense", + "dense", + "dense", + "empty", + "normal", + "normal", + "normal", + "normal", + "dense", + ], +}; diff --git a/frontend/src/components/@shared/ContributionGraph/ContributionGraph.style.ts b/frontend/src/components/@shared/ContributionGraph/ContributionGraph.style.ts new file mode 100644 index 000000000..556b1dc70 --- /dev/null +++ b/frontend/src/components/@shared/ContributionGraph/ContributionGraph.style.ts @@ -0,0 +1,20 @@ +import defaultImage from "../../../assets/images/default-image.png"; +import styled from "styled-components"; + +export const Container = styled.div<{ columnCount: number; rowCount: number }>` + display: grid; + width: 100%; + height: 100%; + ${({ columnCount, rowCount }) => ` + grid-template-columns: repeat(${columnCount}, 1fr); + grid-template-rows: repeat(${rowCount}, 1fr); + `}; + grid-column-gap: 0.1875rem; + grid-row-gap: 0.1875rem; + grid-auto-flow: column; +`; + +export const ContributionItem = styled.div<{ backgroundColor: string }>` + background-color: ${({ backgroundColor }) => backgroundColor}; + border-radius: 3px; +`; diff --git a/frontend/src/components/@shared/ContributionGraph/ContributionGraph.tsx b/frontend/src/components/@shared/ContributionGraph/ContributionGraph.tsx new file mode 100644 index 000000000..04df88751 --- /dev/null +++ b/frontend/src/components/@shared/ContributionGraph/ContributionGraph.tsx @@ -0,0 +1,42 @@ +import { useContext } from "react"; +import { ThemeContext } from "styled-components"; +import { Container, ContributionItem } from "./ContributionGraph.style"; + +export interface Props extends React.HTMLAttributes { + contributionItems: Array<"empty" | "normal" | "dense">; + columnCount?: number; + rowCount?: number; + denseCommitColor?: string; + normalCommitColor?: string; + emptyCommitColor?: string; +} + +const ContributionGraph = ({ + contributionItems, + columnCount = 16, + rowCount = 4, + denseCommitColor, + normalCommitColor, + emptyCommitColor, + ...props +}: Props) => { + const theme = useContext(ThemeContext); + + const contributionItemColor = { + dense: denseCommitColor ?? theme.color.primaryColor, + normal: normalCommitColor ?? theme.color.secondaryColor, + empty: emptyCommitColor ?? theme.color.tertiaryColor, + }; + + const ContributionItemList = contributionItems.map((contributionItem) => ( + + )); + + return ( + + {ContributionItemList} + + ); +}; + +export default ContributionGraph; diff --git a/frontend/src/components/@shared/CountIndicator/CountIndicator.stories.tsx b/frontend/src/components/@shared/CountIndicator/CountIndicator.stories.tsx new file mode 100644 index 000000000..cefa867a0 --- /dev/null +++ b/frontend/src/components/@shared/CountIndicator/CountIndicator.stories.tsx @@ -0,0 +1,17 @@ +import { Story } from "@storybook/react"; + +import CountIndicator, { Props } from "./CountIndicator"; + +export default { + title: "Components/Shared/CountIndicator", + component: CountIndicator, +}; + +const Template: Story = (args) => ; + +export const Default = Template.bind({}); + +Default.args = { + name: "게시물", + count: 102, +}; diff --git a/frontend/src/components/@shared/CountIndicator/CountIndicator.style.ts b/frontend/src/components/@shared/CountIndicator/CountIndicator.style.ts new file mode 100644 index 000000000..0d9bdfa78 --- /dev/null +++ b/frontend/src/components/@shared/CountIndicator/CountIndicator.style.ts @@ -0,0 +1,22 @@ +import styled from "styled-components"; + +export const Container = styled.div` + display: inline-flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + + width: fit-content; + height: 2.6875rem; + line-height: 0.9; + color: ${({ theme }) => theme.color.textColor}; +`; + +export const Count = styled.div` + font-size: 1rem; + font-weight: bold; +`; + +export const Name = styled.div` + font-size: 0.875rem; +`; diff --git a/frontend/src/components/@shared/CountIndicator/CountIndicator.tsx b/frontend/src/components/@shared/CountIndicator/CountIndicator.tsx new file mode 100644 index 000000000..382ad1cb3 --- /dev/null +++ b/frontend/src/components/@shared/CountIndicator/CountIndicator.tsx @@ -0,0 +1,15 @@ +import { Container, Count, Name } from "./CountIndicator.style"; + +export interface Props { + name: string; + count: number; +} + +const CountIndicator = ({ name, count }: Props) => ( + + {count} + {name} + +); + +export default CountIndicator; diff --git a/frontend/src/components/@shared/ImageSlider/ImageSlider.stories.tsx b/frontend/src/components/@shared/ImageSlider/ImageSlider.stories.tsx new file mode 100644 index 000000000..5efe18196 --- /dev/null +++ b/frontend/src/components/@shared/ImageSlider/ImageSlider.stories.tsx @@ -0,0 +1,21 @@ +import { Story } from "@storybook/react"; + +import ImageSlider, { Props } from "./ImageSlider"; + +export default { + title: "Components/Shared/ImageSlider", + component: ImageSlider, +}; + +const Template: Story = (args) => ; + +export const inBox = Template.bind({}); +inBox.args = { + imageUrls: [ + "https://images.unsplash.com/photo-1625776730059-488c148cf868?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=1825&q=80", + "https://images.unsplash.com/photo-1625777719145-b3a5ff913418?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=934&q=80", + "https://images.unsplash.com/photo-1625851740514-6ce39269bc40?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=1275&q=80", + ], + slideButtonKind: "in-box", + width: "100%", +}; diff --git a/frontend/src/components/@shared/ImageSlider/ImageSlider.style.ts b/frontend/src/components/@shared/ImageSlider/ImageSlider.style.ts new file mode 100644 index 000000000..dc86df447 --- /dev/null +++ b/frontend/src/components/@shared/ImageSlider/ImageSlider.style.ts @@ -0,0 +1,64 @@ +import styled from "styled-components"; + +export const Container = styled.div` + ${({ width }) => ` + width: ${width}; + `} + + overflow-x: hidden; + position: relative; +`; + +export const ImageListSlider = styled.ul` + ${({ width }) => ` + width: ${width}; + `} + height: fit-content; + background-color: #000000; + + display: flex; + align-items: center; + transition: transform 0.5s ease-in-out; +`; + +export const ImageList = styled.li` + width: 100%; + + img { + width: 100%; + } +`; + +export const SlideButton = styled.button<{ + direction: "left" | "right"; +}>` + display: flex; + justify-content: center; + align-items: center; + + position: absolute; + top: 50%; + ${({ direction }) => ` + ${direction}: 0.25rem; + `} + + transform: translateY(-50%); + + width: 2.2rem; + height: 2.2rem; + border-radius: 50%; + opacity: 0.4; + + ${({ theme }) => ` + color: ${theme.color.white}; + background-color: ${theme.color.primaryColor}; + `} + + :hover { + opacity: 0.7; + } + + :active { + filter: brightness(1.2); + } +`; diff --git a/frontend/src/components/@shared/ImageSlider/ImageSlider.tsx b/frontend/src/components/@shared/ImageSlider/ImageSlider.tsx new file mode 100644 index 000000000..7970dd661 --- /dev/null +++ b/frontend/src/components/@shared/ImageSlider/ImageSlider.tsx @@ -0,0 +1,80 @@ +import { useContext, useEffect, useRef, useState } from "react"; +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"; + +export interface Props extends React.CSSProperties { + imageUrls: string[]; + slideButtonKind: "in-box" | "stick-out"; +} + +const SLIDE_THROTTLE_DELAY = 800; + +const ImageSlider = ({ imageUrls, slideButtonKind, width }: Props) => { + const [imageIndex, setImageIndex] = useState(0); + const [isFirstImage, setIsFirstImage] = useState(true); + const [isLastImage, setIsLastImage] = useState(false); + + const theme = useContext(ThemeContext); + const imageCount = useRef(imageUrls.length); + const imageListSliderRef = useRef(null); + + const onImageListSliderScroll: React.WheelEventHandler = useThrottle((event) => { + if (event.deltaX > 0 && !isLastImage) { + setImageIndex((prevIndex) => prevIndex + 1); + } else if (event.deltaX < 0 && !isFirstImage) { + setImageIndex((prevIndex) => prevIndex - 1); + } + }, SLIDE_THROTTLE_DELAY); + + const onBackButtonClick: React.MouseEventHandler = () => { + if (isFirstImage) return; + + setImageIndex((prevIndex) => prevIndex - 1); + }; + + const onForwardButtonClick: React.MouseEventHandler = () => { + if (isLastImage) return; + + setImageIndex((prevIndex) => prevIndex + 1); + }; + + useEffect(() => { + setIsFirstImage(imageIndex === 0); + setIsLastImage(imageIndex === imageUrls.length - 1); + + if (imageListSliderRef.current) { + imageListSliderRef.current.style.transform = `translateX(-${(100 * imageIndex) / imageUrls.length}%`; + } + }, [imageIndex, imageUrls.length]); + + return ( + + + {imageUrls.map((imageUrl, index) => ( + + {`${index}번째 + + ))} + + {!isFirstImage && ( + + + + )} + {!isLastImage && ( + + + + )} + + ); +}; + +export default ImageSlider; diff --git a/frontend/src/components/@shared/ImageUploader/ImageUploader.stories.tsx b/frontend/src/components/@shared/ImageUploader/ImageUploader.stories.tsx new file mode 100644 index 000000000..d337b1a59 --- /dev/null +++ b/frontend/src/components/@shared/ImageUploader/ImageUploader.stories.tsx @@ -0,0 +1,21 @@ +import { Story } from "@storybook/react"; + +import ImageUploader, { Props } from "./ImageUploader"; + +export default { + title: "Components/Shared/ImageUploader", + component: ImageUploader, +}; + +const Template: Story = (args) => ; + +export const Default = Template.bind({}); +Default.args = { + onFileListSave: (fileList: FileList) => { + const message = Array.from(fileList) + .map((file) => file.name) + .join(",") + .concat(" 이미지를 저장했습니다."); + alert(message); + }, +}; diff --git a/frontend/src/components/@shared/ImageUploader/ImageUploader.style.ts b/frontend/src/components/@shared/ImageUploader/ImageUploader.style.ts new file mode 100644 index 000000000..013eddc58 --- /dev/null +++ b/frontend/src/components/@shared/ImageUploader/ImageUploader.style.ts @@ -0,0 +1,13 @@ +import styled from "styled-components"; + +export const Container = styled.div``; + +export const Image = styled.img` + transition: opacity 0.5s, box-shadow 0.5s; + cursor: pointer; + + :hover { + opacity: 0.85; + box-shadow: 1px 2px 6px rgba(0, 0, 0, 0.2); + } +`; diff --git a/frontend/src/components/@shared/ImageUploader/ImageUploader.tsx b/frontend/src/components/@shared/ImageUploader/ImageUploader.tsx new file mode 100644 index 000000000..dddde7a5b --- /dev/null +++ b/frontend/src/components/@shared/ImageUploader/ImageUploader.tsx @@ -0,0 +1,40 @@ +import { useRef } from "react"; +import defaultImage from "../../../assets/images/default-image.png"; +import { Container, Image } from "./ImageUploader.style"; + +export interface Props extends React.HTMLAttributes { + defaultImageSrc?: string; + onFileListSave: (fileList: FileList) => void; +} + +const ImageUploader = ({ defaultImageSrc = defaultImage, onFileListSave, ...props }: Props) => { + const fileInputRef = useRef(null); + + const handleImageUpload = () => { + fileInputRef.current?.click(); + }; + + const handleFileSave: React.ChangeEventHandler = (event) => { + if (!event.currentTarget.files) { + return; + } + + onFileListSave(event.currentTarget.files); + }; + + return ( + + + + + ); +}; + +export default ImageUploader; diff --git a/frontend/src/components/@shared/InfiniteScrollContainer/InfiniteScrollContainer.stories.tsx b/frontend/src/components/@shared/InfiniteScrollContainer/InfiniteScrollContainer.stories.tsx new file mode 100644 index 000000000..c6d050fc8 --- /dev/null +++ b/frontend/src/components/@shared/InfiniteScrollContainer/InfiniteScrollContainer.stories.tsx @@ -0,0 +1,17 @@ +import { Story } from "@storybook/react"; + +import InfiniteScrollContainer, { Props } from "./InfiniteScrollContainer"; + +export default { + title: "Components/Shared/InfiniteScrollContainer", + component: InfiniteScrollContainer, +}; + +const Template: Story = (args) => 깃들다; + +export const Default = Template.bind({}); +Default.args = { + onIntersect: () => { + alert("교차됨!"); + }, +}; diff --git a/frontend/src/components/@shared/InfiniteScrollContainer/InfiniteScrollContainer.style.ts b/frontend/src/components/@shared/InfiniteScrollContainer/InfiniteScrollContainer.style.ts new file mode 100644 index 000000000..2015c7342 --- /dev/null +++ b/frontend/src/components/@shared/InfiniteScrollContainer/InfiniteScrollContainer.style.ts @@ -0,0 +1,11 @@ +import styled from "styled-components"; + +export const Container = styled.div``; + +export const ContentWrapper = styled.div``; + +export const LoaderWrapper = styled.div` + display: flex; + justify-content: center; + padding: 2rem 0; +`; diff --git a/frontend/src/components/@shared/InfiniteScrollContainer/InfiniteScrollContainer.tsx b/frontend/src/components/@shared/InfiniteScrollContainer/InfiniteScrollContainer.tsx new file mode 100644 index 000000000..761658adb --- /dev/null +++ b/frontend/src/components/@shared/InfiniteScrollContainer/InfiniteScrollContainer.tsx @@ -0,0 +1,35 @@ +import { useEffect, useRef } from "react"; +import Loader from "../Loader/Loader"; +import { Container, LoaderWrapper, ContentWrapper } from "./InfiniteScrollContainer.style"; + +export interface Props extends React.HTMLAttributes { + isLoaderShown: boolean; + onIntersect: () => void; +} + +const InfiniteScrollContainer = ({ isLoaderShown, onIntersect, children }: Props) => { + const loaderRef = useRef(null); + + const observer = new IntersectionObserver((entries) => { + const [entry] = entries; + + if (!entry.isIntersecting) { + return; + } + + onIntersect(); + }); + + useEffect(() => { + loaderRef.current && observer.observe(loaderRef.current); + }, [loaderRef]); + + return ( + + {children} + {isLoaderShown && } + + ); +}; + +export default InfiniteScrollContainer; diff --git a/frontend/src/components/@shared/Input/Input.stories.tsx b/frontend/src/components/@shared/Input/Input.stories.tsx new file mode 100644 index 000000000..cf4ad2c2e --- /dev/null +++ b/frontend/src/components/@shared/Input/Input.stories.tsx @@ -0,0 +1,19 @@ +import { Story } from "@storybook/react"; +import { SearchIcon } from "../../../assets/icons"; + +import Input, { Props } from "./Input"; + +export default { + title: "Components/Shared/Input", + component: Input, +}; + +const Template: Story = (args) => ; + +export const Default = Template.bind({}); +Default.args = {}; + +export const InputWithIcon = Template.bind({}); +InputWithIcon.args = { + icon: , +}; diff --git a/frontend/src/components/@shared/Input/Input.style.ts b/frontend/src/components/@shared/Input/Input.style.ts new file mode 100644 index 000000000..d4f800ad2 --- /dev/null +++ b/frontend/src/components/@shared/Input/Input.style.ts @@ -0,0 +1,54 @@ +import styled from "styled-components"; + +export interface StyleProps extends RoundedContainerProps, BottomBorderContainerProps, InputProps {} + +interface RoundedContainerProps { + backgroundColor?: string; +} + +interface BottomBorderContainerProps { + bottomBorderColor?: string; +} + +interface InputProps { + textAlign?: "left" | "center"; +} + +export const Input = styled.input` + display: block; + width: 100%; + border: none; + text-align: ${({ textAlign }) => (textAlign ? textAlign : "left")}; + background: none; + font-size: 12px; + + :focus { + outline: none; + } +`; + +export const RoundedInputContainer = styled.div` + display: flex; + align-items: center; + border-radius: 8px; + padding: 0.6875rem 1.125rem; + background-color: ${({ theme, backgroundColor }) => (backgroundColor ? backgroundColor : theme.color.secondaryColor)}; + transition: background-color 0.5s; + cursor: text; + :focus-within { + background-color: ${({ theme }) => theme.color.tagItemColor}; + } +`; + +export const BottomBorderInputContainer = styled.div` + display: flex; + align-items: center; + border-bottom: 2px solid + ${({ theme, bottomBorderColor }) => (bottomBorderColor ? bottomBorderColor : theme.color.secondaryColor)}; + padding: 0.6875rem 1.125rem; + transition: border-bottom-color 0.5s; + cursor: text; + :focus-within { + border-bottom-color: ${({ theme }) => theme.color.tagItemColor}; + } +`; diff --git a/frontend/src/components/@shared/Input/Input.tsx b/frontend/src/components/@shared/Input/Input.tsx new file mode 100644 index 000000000..d7f4f63f8 --- /dev/null +++ b/frontend/src/components/@shared/Input/Input.tsx @@ -0,0 +1,52 @@ +import { useRef } from "react"; +import { Input as StyledInput, BottomBorderInputContainer, RoundedInputContainer, StyleProps } from "./Input.style"; + +export interface Props extends React.HTMLAttributes, StyleProps { + kind?: "borderBottom" | "rounded"; + icon?: React.ReactNode; + name?: string; +} + +const Input = ({ kind, icon, textAlign = "left", backgroundColor, bottomBorderColor, name, ...props }: Props) => { + const inputRef = useRef(null); + const input = ( + <> + {icon} + + + ); + + const triggerInputFocus = () => { + inputRef.current && inputRef.current.focus(); + }; + + if (kind === "borderBottom") { + return ( + + {input} + + ); + } + + if (kind === "rounded") { + return ( + + {input} + + ); + } + + return ( + + {input} + + ); +}; + +export default Input; diff --git a/frontend/src/components/@shared/Loader/Loader.stories.tsx b/frontend/src/components/@shared/Loader/Loader.stories.tsx new file mode 100644 index 000000000..3bfd44acc --- /dev/null +++ b/frontend/src/components/@shared/Loader/Loader.stories.tsx @@ -0,0 +1,24 @@ +import { Story } from "@storybook/react"; + +import Loader, { Props } from "./Loader"; + +export default { + title: "Components/Shared/Loader", + component: Loader, +}; + +const Template: Story = (args) => 깃들다; + +export const Dots = Template.bind({}); + +export const Spinner = Template.bind({}); + +Dots.args = { + kind: "dots", + size: "1rem", +}; + +Spinner.args = { + kind: "spinner", + size: "1.6rem", +}; diff --git a/frontend/src/components/@shared/Loader/Loader.style.ts b/frontend/src/components/@shared/Loader/Loader.style.ts new file mode 100644 index 000000000..960587103 --- /dev/null +++ b/frontend/src/components/@shared/Loader/Loader.style.ts @@ -0,0 +1,59 @@ +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); + } +`; + +export const LoadingDots = styled.div``; + +const LoadingDot = styled.div<{ size: string }>` + ${({ theme, size }) => ` + width: ${size}; + height: ${size}; + background-color: ${theme.color.tagItemColor} + `}; + border-radius: 50%; + display: inline-block; + animation: 1.5s ${bounceAnimation} infinite ease-in-out both; +`; + +export const FirstLoadingDot = styled(LoadingDot)` + margin-right: 0.3rem; +`; + +export const SecondLoadingDot = styled(LoadingDot)` + margin-right: 0.3rem; + animation-delay: 0.15s; +`; + +export const ThirdLoadingDot = styled(LoadingDot)` + animation-delay: 0.3s; +`; + +export const Spinner = styled.div<{ size: string }>` + ${({ theme, size }) => ` + width: ${size}; + height: ${size}; + border: 3px solid ${theme.color.tagItemColor}; + border-top-color: ${theme.color.primaryColor}; + `}; + + display: inline-block; + border-radius: 50%; + animation: 1s ${spinAnimation} infinite ease-in-out; +`; diff --git a/frontend/src/components/@shared/Loader/Loader.tsx b/frontend/src/components/@shared/Loader/Loader.tsx new file mode 100644 index 000000000..342184296 --- /dev/null +++ b/frontend/src/components/@shared/Loader/Loader.tsx @@ -0,0 +1,22 @@ +import { LoadingDots, FirstLoadingDot, SecondLoadingDot, ThirdLoadingDot, Spinner } from "./Loader.style"; + +export interface Props extends React.HTMLAttributes { + kind: "dots" | "spinner"; + size: string; +} + +const Loader = ({ kind, size, ...props }: Props) => { + if (kind === "spinner") { + return ; + } + + return ( + + + + + + ); +}; + +export default Loader; diff --git a/frontend/src/components/@shared/PostItem/PostItem.stories.tsx b/frontend/src/components/@shared/PostItem/PostItem.stories.tsx new file mode 100644 index 000000000..fd0438389 --- /dev/null +++ b/frontend/src/components/@shared/PostItem/PostItem.stories.tsx @@ -0,0 +1,43 @@ +import { Story } from "@storybook/react"; + +import PostItem, { Props } from "./PostItem"; + +export default { + title: "Components/Shared/PostItem", + component: PostItem, +}; + +const Template: Story = (args) => 깃들다; + +export const Default = Template.bind({}); +Default.args = { + authorGithubUrl: "https://github.com/Tanney-102", + authorName: "Tanney102", + authorImageUrl: "https://avatars.githubusercontent.com/u/57767891?v=4", + commenterImageUrl: "https://avatars.githubusercontent.com/u/32982670?v=4", + isEditable: true, + imageUrls: [ + "https://images.unsplash.com/photo-1587620962725-abab7fe55159?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=1489&q=80", + "https://images.unsplash.com/photo-1534665482403-a909d0d97c67?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=1050&q=80", + "https://images.unsplash.com/photo-1521185496955-15097b20c5fe?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=947&q=80", + ], + content: + "가는 그들에게 너의 설산에서 것은 곳으로 힘차게 그러므로 사막이다. 피어나는 않는 영원히 우리의 용기가 풍부하게 교향악이다. 예가 황금시대를 열락의 같이, 있다. 소금이라 피가 황금시대의 때에, 것이다. 생생하며, 원질이 찬미를 주는 고동을 튼튼하며, 그들은 이상의 피다. 살았으며, 행복스럽고 방황하였으며, 쓸쓸하랴? 가는 생생하며, 되는 싸인 불러 인간에 소금이라 방황하여도, 사랑의 힘있다. 인도하겠다는 대한 청춘을 봄바람이다. 불어 끓는 쓸쓸한 거친 무엇을 내려온 장식하는 것이다. 열매를 끓는 끓는 착목한는 쓸쓸한 봄바람이다. 원대하고, 사랑의 살 부패를 없으면, 그들은 이것을 그들의 고동을 것이다. 타오르고 공자는 천고에 미묘한 듣기만 것이다. 것은 이상을 피어나기 피다. 방황하였으며, 돋고, 이상은 이상 귀는 길지 내는 발휘하기 풍부하게 아니다. 끝에 그러므로 밝은 하였으며, 것이다. 옷을 공자는 있으며, 놀이 이상을 끓는다. 새 그들은 청춘이 없는 어디 사는가 힘차게 위하여, 있다. 원질이 오아이스도 그들의 철환하였는가? 끓는 만천하의 듣기만 없으면 이상의 노래하며 이것을 것이다. 현저하게 살았으며, 대한 같지 아니다.", + comments: [ + { + commentId: "1", + authorName: "swon3210", + content: "이게 댓글이지", + isLiked: true, + }, + { + commentId: "2", + authorName: "swon3210", + content: "이게 댓글이지", + isLiked: true, + }, + ], + createdAt: "3일 전", + isLiked: true, + likeCount: 32, +}; diff --git a/frontend/src/components/@shared/PostItem/PostItem.style.ts b/frontend/src/components/@shared/PostItem/PostItem.style.ts new file mode 100644 index 000000000..eb74c7466 --- /dev/null +++ b/frontend/src/components/@shared/PostItem/PostItem.style.ts @@ -0,0 +1,114 @@ +import { Link } from "react-router-dom"; +import styled from "styled-components"; + +export const Container = styled.div``; + +export const PostHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem; +`; + +export const PostAuthorInfoLink = styled(Link)` + display: flex; + align-items: center; + transition: opacity 0.5s; + + :hover { + opacity: 0.7; + } +`; + +export const PostAuthorName = styled.span` + margin-left: 1rem; + font-weight: bold; + font-size: 0.875rem; +`; + +export const PostBody = styled.div` + display: flex; + flex-direction: column; + padding: 0.75rem; +`; + +export const IconLink = styled.a` + transition: opacity 0.5s; + + :hover { + opacity: 0.7; + } +`; + +export const IconLinkButton = styled(Link)` + transition: opacity 0.5s; + + :hover { + opacity: 0.5; + } +`; + +export const IconLinkButtonsWrapper = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.4375rem; +`; + +export const LikeCountText = styled.span` + font-weight: bold; + margin-bottom: 0.5rem; + font-size: 0.75rem; +`; + +export const MoreContentLinkButton = styled.a` + font-size: 0.75rem; + font-weight: bold; + margin-left: 0.625rem; + margin-right: 0.3125rem; + float: right; +`; + +export const PostContentAuthorLink = styled(Link)` + margin-right: 0.4375rem; + font-size: 0.75rem; + font-weight: bold; +`; + +export const PostContent = styled.span` + font-size: 0.625rem; + line-height: 1.5rem; + margin-bottom: 1rem; +`; + +export const TagListWrapper = styled.div` + display: flex; + flex-wrap: wrap; + margin-bottom: 1rem; +`; + +export const TagItemLinkButton = styled(Link)` + margin-right: 0.625rem; + margin-bottom: 0.5625rem; +`; + +export const CommentWrapper = styled.div` + margin-bottom: 0.5rem; +`; + +export const MyComment = styled.div` + display: flex; + align-items: center; + padding: 0 0.75rem; +`; + +export const CommentInputWrapper = styled.div` + width: 100%; + padding: 0 1rem; +`; + +export const PostCreatedDateText = styled.span` + padding: 0.75rem; + font-size: 0.6875rem; + font-weight: bold; +`; diff --git a/frontend/src/components/@shared/PostItem/PostItem.tsx b/frontend/src/components/@shared/PostItem/PostItem.tsx new file mode 100644 index 000000000..cc0991357 --- /dev/null +++ b/frontend/src/components/@shared/PostItem/PostItem.tsx @@ -0,0 +1,168 @@ +import { + Container, + MyComment, + CommentInputWrapper, + CommentWrapper, + IconLinkButton, + IconLink, + IconLinkButtonsWrapper, + LikeCountText, + PostAuthorInfoLink, + PostAuthorName, + PostContentAuthorLink, + PostBody, + PostContent, + PostHeader, + TagListWrapper, + TagItemLinkButton, + PostCreatedDateText, + MoreContentLinkButton, +} from "./PostItem.style"; +import Avatar from "../Avatar/Avatar"; +import CircleIcon from "../CircleIcon/CircleIcon"; +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 { 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"; + +export interface Props { + authorName: string; + authorImageUrl: string; + authorGithubUrl: string; + isEditable: boolean; + imageUrls: string[]; + likeCount: number; + isLiked: boolean; + content: string; + comments: CommentData[]; + commenterImageUrl: string; + tags: string[]; + createdAt: string; + commentValue: string; + onCommentValueChange: React.ChangeEventHandler; + onCommentValueSave: () => void; + onPostLike: () => void; + onCommentLike: (commentId: string) => void; +} + +const timeDiffTextTable = { + sec: () => "방금 전", + min: (time: number) => `${time}분 전`, + hour: (time: number) => `${time}시간 전`, + day: (time: number) => `${time}일 전`, +}; + +const PostItem = ({ + authorName, + authorImageUrl, + authorGithubUrl, + isEditable, + imageUrls, + likeCount, + isLiked, + content, + comments, + commenterImageUrl, + tags, + createdAt, + commentValue, + onCommentValueChange, + onCommentValueSave, + onCommentLike, + onPostLike, +}: Props) => { + const [shouldHideContent, setShouldHideContent] = useState(content.length > LIMIT.POST_CONTENT_HIDE_LENGTH); + const { color } = useContext(ThemeContext); + + const { min, hour, day } = getTimeDiffFromCurrent(createdAt); + const currentTimeDiffText = day + ? timeDiffTextTable.day(day) + : hour + ? timeDiffTextTable.hour(hour) + : min + ? timeDiffTextTable.min(min) + : timeDiffTextTable.sec(); + + const commentList = comments.map((comment) => ( + + onCommentLike(comment.commentId)} + /> + + )); + + const tagList = JSON.parse(tags.join(",")).map((tag: string) => ( + + {tag} + + )); + + const onMoreContentShow = () => { + setShouldHideContent(false); + }; + + return ( + + + + + {authorName} + + {isEditable && ( + + + + )} + + + + + {isLiked ? : } + + + + + + + 좋아요 {likeCount}개 + + {authorName} + {shouldHideContent ? content.slice(0, LIMIT.POST_CONTENT_HIDE_LENGTH).concat("...") : content} + {shouldHideContent && 더보기} + + {shouldHideContent || tagList} + {commentList} + + + + + + + + + + + {currentTimeDiffText} + + ); +}; + +export default PostItem; diff --git a/frontend/src/components/@shared/ProfileHeader/ProfileHeader.stories.tsx b/frontend/src/components/@shared/ProfileHeader/ProfileHeader.stories.tsx new file mode 100644 index 000000000..3027e1658 --- /dev/null +++ b/frontend/src/components/@shared/ProfileHeader/ProfileHeader.stories.tsx @@ -0,0 +1,56 @@ +import { Story } from "@storybook/react"; + +import LoggedInWrapper from "../../../../.storybook/utils/LoggedInWrapper"; +import ProfileHeader, { Props } from "./ProfileHeader"; + +export default { + title: "Components/Shared/ProfileHeader", + component: ProfileHeader, +}; + +const mockProfile = { + name: "Beuccol", + image: + "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, + followerCount: 13, + postCount: 102, + githubUrl: "https://github.com/tanney-102", + company: "우아한형제들", + location: "잠실역 9번출구", + website: "https://techcourse.woowahan.com/", + twitter: "", + following: false, +}; + +const DefaultTemplate: Story = (args) => ; +const LoggedInTemplate: Story = (args) => ( + + + +); + +export const Default = DefaultTemplate.bind({}); +Default.args = { + profile: mockProfile, + isMyProfile: false, +}; + +export const MyProfile = LoggedInTemplate.bind({}); +MyProfile.args = { + profile: mockProfile, + isMyProfile: true, +}; + +export const NotFollowed = LoggedInTemplate.bind({}); +NotFollowed.args = { + profile: mockProfile, + isMyProfile: false, +}; + +export const Followed = LoggedInTemplate.bind({}); +Followed.args = { + profile: { ...mockProfile, following: true }, + isMyProfile: false, +}; diff --git a/frontend/src/components/@shared/ProfileHeader/ProfileHeader.style.ts b/frontend/src/components/@shared/ProfileHeader/ProfileHeader.style.ts new file mode 100644 index 000000000..9bfaefe21 --- /dev/null +++ b/frontend/src/components/@shared/ProfileHeader/ProfileHeader.style.ts @@ -0,0 +1,14 @@ +import styled from "styled-components"; + +export const Container = styled.div` + display: flex; + justify-content: space-between; +`; + +export const Indicators = styled.div` + display: flex; + justify-content: space-between; + + width: 13.25rem; + margin-bottom: 0.8125rem; +`; diff --git a/frontend/src/components/@shared/ProfileHeader/ProfileHeader.tsx b/frontend/src/components/@shared/ProfileHeader/ProfileHeader.tsx new file mode 100644 index 000000000..b2a3ab4da --- /dev/null +++ b/frontend/src/components/@shared/ProfileHeader/ProfileHeader.tsx @@ -0,0 +1,88 @@ +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 Avatar from "../Avatar/Avatar"; +import Button from "../Button/Button"; +import CountIndicator from "../CountIndicator/CountIndicator"; +import { Container, Indicators } from "./ProfileHeader.style"; + +export interface Props { + profile?: ProfileData; + isMyProfile: boolean; +} + +const ProfileHeader = ({ profile, isMyProfile }: 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; + + if (profile?.following) { + unFollow(); + } else { + follow(); + } + }; + + const ProfileButton = () => { + if (!isLoggedIn) { + return <>; + } + + if (isFollowLoading || isUnfollowLoading) { + return
loading
; + } + + if (isMyProfile) { + return ( + + ); + } + + if (profile?.following) { + return ( + + ); + } else { + return ( + + ); + } + }; + + return ( + + +
+ + + + + + +
+
+ ); +}; + +export default ProfileHeader; diff --git a/frontend/src/components/@shared/Snackbar/Snackbar.stories.tsx b/frontend/src/components/@shared/Snackbar/Snackbar.stories.tsx new file mode 100644 index 000000000..e9c0b644e --- /dev/null +++ b/frontend/src/components/@shared/Snackbar/Snackbar.stories.tsx @@ -0,0 +1,23 @@ +import { Story, Meta } from "@storybook/react"; + +import SnackBar, { Props } from "./Snackbar"; + +export default { + title: "Components/Shared/SnackBar", + component: SnackBar, +} as Meta; + +const Template: Story = (args) => ; + +export const Order1 = Template.bind({}); +export const Order2 = Template.bind({}); + +Order1.args = { + children: "스낵바입니다.", + order: 1, +}; + +Order2.args = { + children: "스낵바입니다.", + order: 2, +}; diff --git a/frontend/src/components/@shared/Snackbar/Snackbar.style.ts b/frontend/src/components/@shared/Snackbar/Snackbar.style.ts new file mode 100644 index 000000000..ba1f225eb --- /dev/null +++ b/frontend/src/components/@shared/Snackbar/Snackbar.style.ts @@ -0,0 +1,47 @@ +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%); + } +`; + +export const Container = styled.div<{ snackbarDuration: number } & React.CSSProperties>` + display: flex; + justify-content: center; + align-items: center; + + position: fixed; + left: 50%; + bottom: ${({ bottom }) => bottom}; + opacity: 0; + transform: translateX(-50%) translateY(50%); + + min-width: 20rem; + height: 2.3rem; + background-color: rgba(0, 0, 0, 0.5); + border-radius: 0.5rem; + font-size: 0.8rem; + color: ${({ theme }) => theme.color.white}; + padding: 1rem 2rem; + + animation: ${({ animationDuration }) => css` + ${BottomToBottom} ${animationDuration} ease + `}; +`; diff --git a/frontend/src/components/@shared/Snackbar/Snackbar.tsx b/frontend/src/components/@shared/Snackbar/Snackbar.tsx new file mode 100644 index 000000000..53e8f93ca --- /dev/null +++ b/frontend/src/components/@shared/Snackbar/Snackbar.tsx @@ -0,0 +1,24 @@ +import { Container } from "./Snackbar.style"; +import { SNACKBAR_GAP_REM, SNACKBAR_HEIGHT_REM, SnackBarOrder, SNACKBAR_DURATION } from "../../../constants/snackbar"; + +const getSnackBarBottom = (order: SnackBarOrder) => + `${order * (SNACKBAR_HEIGHT_REM + SNACKBAR_GAP_REM) - SNACKBAR_HEIGHT_REM}rem`; + +export interface Props { + children: string; + order: SnackBarOrder; +} + +const SnackBar = ({ children, order }: Props) => { + return ( + + {children} + + ); +}; + +export default SnackBar; diff --git a/frontend/src/components/@shared/Tabs/Tabs.stories.tsx b/frontend/src/components/@shared/Tabs/Tabs.stories.tsx new file mode 100644 index 000000000..27c02c445 --- /dev/null +++ b/frontend/src/components/@shared/Tabs/Tabs.stories.tsx @@ -0,0 +1,24 @@ +import { Story } from "@storybook/react"; + +import Tabs, { Props } from "./Tabs"; + +export default { + title: "Components/Shared/Tabs", + component: Tabs, +}; + +const Template: Story = (args) => ; + +export const Default = Template.bind({}); +Default.args = { + tabItems: [ + { + name: "포스트", + content: "포스트 내용", + }, + { + name: "Github 통계", + content: "활동 통계 내용", + }, + ], +}; diff --git a/frontend/src/components/@shared/Tabs/Tabs.style.ts b/frontend/src/components/@shared/Tabs/Tabs.style.ts new file mode 100644 index 000000000..301c920be --- /dev/null +++ b/frontend/src/components/@shared/Tabs/Tabs.style.ts @@ -0,0 +1,55 @@ +import styled from "styled-components"; + +export const Container = styled.section` + width: 100%; + overflow: hidden; +`; + +export const TabButtonWrapper = styled.div` + display: flex; + position: relative; + justify-content: space-between; +`; + +export const TabButton = styled.a<{ textColor?: string }>` + width: 100%; + flex-grow: 1; + padding: 0.625rem; + text-align: center; + font-weight: 600; + overflow: hidden; + color: ${({ theme, textColor }) => (textColor ? textColor : theme.color.textColor)}; + + transition: background-color 0.5s; + :hover { + background-color: #eee; + } +`; + +export const TabIndicator = styled.div<{ 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}%; +`; diff --git a/frontend/src/components/@shared/Tabs/Tabs.tsx b/frontend/src/components/@shared/Tabs/Tabs.tsx new file mode 100644 index 000000000..a7fcc442d --- /dev/null +++ b/frontend/src/components/@shared/Tabs/Tabs.tsx @@ -0,0 +1,49 @@ +import { useContext, useState } from "react"; + +import { ThemeContext } from "styled-components"; +import { Container, TabIndicator, TabButton, TabButtonWrapper, TabContentWrapper, TabContent } from "./Tabs.style"; + +export interface Props extends React.HTMLAttributes { + tabItems: { + name: string; + content: React.ReactNode; + }[]; + tabIndicatorColor?: string; +} + +const Tabs = ({ tabItems, tabIndicatorColor, ...props }: Props) => { + const { color } = useContext(ThemeContext); + const [tabIndex, setTabIndex] = useState(0); + + const handleTabIndexChange = (index: number) => { + setTabIndex(index); + }; + + const tabButtonList = tabItems.map((tabItem, index) => ( + handleTabIndexChange(index)} + > + {tabItem.name} + + )); + + return ( + + + {tabButtonList} + + + + {tabItems.map(({ name, content }) => ( + + {content} + + ))} + + + ); +}; + +export default Tabs; diff --git a/frontend/src/components/@shared/TextEditor/TextEditor.stories.tsx b/frontend/src/components/@shared/TextEditor/TextEditor.stories.tsx new file mode 100644 index 000000000..9e13eb6ca --- /dev/null +++ b/frontend/src/components/@shared/TextEditor/TextEditor.stories.tsx @@ -0,0 +1,57 @@ +import { Story } from "@storybook/react"; +import { ChangeEventHandler, useState } from "react"; + +import TextEditor, { Props } from "./TextEditor"; +import { TextEditorWrapper } from "../../../../.storybook/utils/components"; + +type ContainerProps = Omit; + +const Container = (args: ContainerProps) => { + const [value, setValue] = useState(""); + const onChange: ChangeEventHandler = ({ target: { value } }) => setValue(value); + + return ( + + + + ); +}; + +const TransparentContainer = (args: ContainerProps) => { + const [value, setValue] = useState(""); + const onChange: ChangeEventHandler = ({ target: { value } }) => setValue(value); + + return ; +}; + +export default { + title: "Components/Shared/TextEditor", + component: Container, +}; + +const Template: Story = (args) => ; + +const TransparentTemplate: Story = (args) => ; + +export const Default = Template.bind({}); +Default.args = { + width: "100%", + height: "200px", + placeholder: "내용을 입력해주세요", +}; + +export const AutoGrow = Template.bind({}); +AutoGrow.args = { + width: "100%", + height: "200px", + placeholder: "내용을 입력해주세요", + autoGrow: true, +}; + +export const Transparent = TransparentTemplate.bind({}); +Transparent.args = { + width: "100%", + height: "200px", + placeholder: "내용을 입력해주세요", + autoGrow: true, +}; diff --git a/frontend/src/components/@shared/TextEditor/TextEditor.style.ts b/frontend/src/components/@shared/TextEditor/TextEditor.style.ts new file mode 100644 index 000000000..a3942d826 --- /dev/null +++ b/frontend/src/components/@shared/TextEditor/TextEditor.style.ts @@ -0,0 +1,15 @@ +import styled from "styled-components"; + +const TextArea = styled.textarea` + ${({ width, height, minHeight, fontSize }) => ` + width: ${width ?? "100%"}; + min-height: ${minHeight ?? "fit-content"}; + height: ${height ?? "fit-content"}; + font-size: ${fontSize ?? "1rem"}; + `} + + border: none; + outline: none; +`; + +export default TextArea; diff --git a/frontend/src/components/@shared/TextEditor/TextEditor.tsx b/frontend/src/components/@shared/TextEditor/TextEditor.tsx new file mode 100644 index 000000000..f4f3ac417 --- /dev/null +++ b/frontend/src/components/@shared/TextEditor/TextEditor.tsx @@ -0,0 +1,46 @@ +import { ChangeEventHandler, KeyboardEventHandler, useState } from "react"; + +import TextArea from "./TextEditor.style"; + +export interface Props { + width?: string; + height?: string; + fontSize?: string; + autoGrow?: boolean; + placeholder?: string; + value: string; + onChange: ChangeEventHandler; +} + +const TEXT_EDITOR_LINE_HEIGHT = 1.2; + +const TextEditor = ({ width, height, fontSize = "1rem", autoGrow = false, placeholder, value, onChange }: Props) => { + const [currentHeight, setCurrentHeight] = useState(""); + + const onKeyUp: KeyboardEventHandler = ({ key }) => { + if (!autoGrow) return; + + if (key === "Enter" || key === "Backspace" || key === "Delete") { + const lineCount = (value ?? "").split("\n").length + 1; + const fontSizeNumber = fontSize.replace(/[^0-9]/g, ""); + const fontSizeMeasure = fontSize.replace(/[0-9]/g, ""); + + setCurrentHeight(`${lineCount * Number(fontSizeNumber) * TEXT_EDITOR_LINE_HEIGHT}${fontSizeMeasure}`); + } + }; + + return ( +