diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e30cbf --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +.DS_Store +.gradle +/build/ +!gradle/wrapper/gradle-wrapper.jar +/out/ +/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr diff --git a/README.md b/README.md index 60b6c44..8262061 100644 --- a/README.md +++ b/README.md @@ -1 +1,10 @@ -# java-bowling \ No newline at end of file +# java-bowling +# 볼링 게임 점수판 +## 진행 방법 +* 볼링 게임 점수판 요구사항을 파악한다. +* 요구사항에 대한 구현을 완료한 후 자신의 github 아이디에 해당하는 브랜치에 Pull Request(이하 PR)를 통해 코드 리뷰 요청을 한다. +* 코드 리뷰 피드백에 대한 개선 작업을 하고 다시 PUSH한다. +* 모든 피드백을 완료하면 다음 단계를 도전하고 앞의 과정을 반복한다. + +## 온라인 코드 리뷰 과정 +* [텍스트와 이미지로 살펴보는 온라인 코드 리뷰 과정](https://github.com/next-step/nextstep-docs/tree/master/codereview) diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..e89ad5f --- /dev/null +++ b/build.gradle @@ -0,0 +1,36 @@ +buildscript { + ext { + springBootVersion = '2.2.6.RELEASE' + } + repositories { + mavenCentral() + } + dependencies { + classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}") + } +} + +apply plugin: 'java' +apply plugin: 'eclipse' +apply plugin: 'org.springframework.boot' +apply plugin: 'io.spring.dependency-management' + +version = '1.0.0' +sourceCompatibility = 1.8 + +repositories { + mavenCentral() +} + +dependencies { + compile('org.springframework.boot:spring-boot-starter-data-jpa') + compile('org.hibernate:hibernate-java8') + runtime('com.h2database:h2') + testCompile('org.junit.jupiter:junit-jupiter:5.6.0') + testCompile('org.assertj:assertj-core:3.15.0') + testCompile('org.springframework.boot:spring-boot-starter-test') +} + +test { + useJUnitPlatform() +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..87b738c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..4f77ab0 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Sat Apr 04 17:02:42 KST 2020 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.3-all.zip diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..af6708f --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## 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"' + +# 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, switch paths to Windows format before running java +if $cygwin ; 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=$((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" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..6d57edc --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@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 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" + +@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 init + +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 init + +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 + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +: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 %CMD_LINE_ARGS% + +: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/src/main/java/bowling/empty.txt b/src/main/java/bowling/empty.txt new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/qna/CannotDeleteException.java b/src/main/java/qna/CannotDeleteException.java new file mode 100644 index 0000000..12ea9bc --- /dev/null +++ b/src/main/java/qna/CannotDeleteException.java @@ -0,0 +1,9 @@ +package qna; + +public class CannotDeleteException extends Exception { + private static final long serialVersionUID = 1L; + + public CannotDeleteException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/qna/ForbiddenException.java b/src/main/java/qna/ForbiddenException.java new file mode 100644 index 0000000..e9540d5 --- /dev/null +++ b/src/main/java/qna/ForbiddenException.java @@ -0,0 +1,10 @@ +package qna; + +public class ForbiddenException extends RuntimeException{ + public ForbiddenException() { + } + + public ForbiddenException(String message) { + super(message); + } +} diff --git a/src/main/java/qna/NotFoundException.java b/src/main/java/qna/NotFoundException.java new file mode 100644 index 0000000..5fd0ff0 --- /dev/null +++ b/src/main/java/qna/NotFoundException.java @@ -0,0 +1,4 @@ +package qna; + +public class NotFoundException extends RuntimeException { +} diff --git a/src/main/java/qna/QnaApplication.java b/src/main/java/qna/QnaApplication.java new file mode 100755 index 0000000..105b77a --- /dev/null +++ b/src/main/java/qna/QnaApplication.java @@ -0,0 +1,15 @@ +package qna; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.EnableAspectJAutoProxy; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@SpringBootApplication +@EnableJpaAuditing +@EnableAspectJAutoProxy(proxyTargetClass = true) +public class QnaApplication { + public static void main(String[] args) { + SpringApplication.run(QnaApplication.class, args); + } +} diff --git a/src/main/java/qna/UnAuthenticationException.java b/src/main/java/qna/UnAuthenticationException.java new file mode 100644 index 0000000..a75ff49 --- /dev/null +++ b/src/main/java/qna/UnAuthenticationException.java @@ -0,0 +1,26 @@ +package qna; + +public class UnAuthenticationException extends Exception { + private static final long serialVersionUID = 1L; + + public UnAuthenticationException() { + super(); + } + + public UnAuthenticationException(String message, Throwable cause, boolean enableSuppression, + boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } + + public UnAuthenticationException(String message, Throwable cause) { + super(message, cause); + } + + public UnAuthenticationException(String message) { + super(message); + } + + public UnAuthenticationException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/qna/UnAuthorizedException.java b/src/main/java/qna/UnAuthorizedException.java new file mode 100644 index 0000000..5acd14e --- /dev/null +++ b/src/main/java/qna/UnAuthorizedException.java @@ -0,0 +1,26 @@ +package qna; + +public class UnAuthorizedException extends RuntimeException { + private static final long serialVersionUID = 1L; + + public UnAuthorizedException() { + super(); + } + + public UnAuthorizedException(String message, Throwable cause, boolean enableSuppression, + boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } + + public UnAuthorizedException(String message, Throwable cause) { + super(message, cause); + } + + public UnAuthorizedException(String message) { + super(message); + } + + public UnAuthorizedException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/qna/domain/AbstractEntity.java b/src/main/java/qna/domain/AbstractEntity.java new file mode 100644 index 0000000..117ce7c --- /dev/null +++ b/src/main/java/qna/domain/AbstractEntity.java @@ -0,0 +1,70 @@ +package qna.domain; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import javax.persistence.*; +import java.time.LocalDateTime; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public class AbstractEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, updatable = false) + @CreatedDate + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; + + public AbstractEntity() { + } + + public AbstractEntity(Long id) { + this.id = id; + } + + public Long getId() { + return id; + } + + public AbstractEntity setId(Long id) { + this.id = id; + return this; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + (int) (id ^ (id >>> 32)); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + AbstractEntity other = (AbstractEntity) obj; + if (id != other.id) + return false; + return true; + } + + @Override + public String toString() { + return "AbstractEntity{" + + "id=" + id + + ", createdAt=" + createdAt + + ", updatedAt=" + updatedAt + + '}'; + } +} diff --git a/src/main/java/qna/domain/Answer.java b/src/main/java/qna/domain/Answer.java new file mode 100644 index 0000000..548b71e --- /dev/null +++ b/src/main/java/qna/domain/Answer.java @@ -0,0 +1,75 @@ +package qna.domain; + +import qna.NotFoundException; +import qna.UnAuthorizedException; + +import javax.persistence.*; + +@Entity +public class Answer extends AbstractEntity { + @ManyToOne(optional = false) + @JoinColumn(foreignKey = @ForeignKey(name = "fk_answer_writer")) + private User writer; + + @ManyToOne(optional = false) + @JoinColumn(foreignKey = @ForeignKey(name = "fk_answer_to_question")) + private Question question; + + @Lob + private String contents; + + private boolean deleted = false; + + public Answer() { + } + + public Answer(User writer, Question question, String contents) { + this(null, writer, question, contents); + } + + public Answer(Long id, User writer, Question question, String contents) { + super(id); + + if(writer == null) { + throw new UnAuthorizedException(); + } + + if(question == null) { + throw new NotFoundException(); + } + + this.writer = writer; + this.question = question; + this.contents = contents; + } + + public Answer setDeleted(boolean deleted) { + this.deleted = deleted; + return this; + } + + public boolean isDeleted() { + return deleted; + } + + public boolean isOwner(User writer) { + return this.writer.equals(writer); + } + + public User getWriter() { + return writer; + } + + public String getContents() { + return contents; + } + + public void toQuestion(Question question) { + this.question = question; + } + + @Override + public String toString() { + return "Answer [id=" + getId() + ", writer=" + writer + ", contents=" + contents + "]"; + } +} diff --git a/src/main/java/qna/domain/AnswerRepository.java b/src/main/java/qna/domain/AnswerRepository.java new file mode 100644 index 0000000..872e1cd --- /dev/null +++ b/src/main/java/qna/domain/AnswerRepository.java @@ -0,0 +1,12 @@ +package qna.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface AnswerRepository extends JpaRepository { + List findByQuestionAndDeletedFalse(Question question); + + Optional findByIdAndDeletedFalse(Long id); +} diff --git a/src/main/java/qna/domain/ContentType.java b/src/main/java/qna/domain/ContentType.java new file mode 100644 index 0000000..55bc6a2 --- /dev/null +++ b/src/main/java/qna/domain/ContentType.java @@ -0,0 +1,5 @@ +package qna.domain; + +public enum ContentType { + QUESTION, ANSWER; +} diff --git a/src/main/java/qna/domain/DeleteHistory.java b/src/main/java/qna/domain/DeleteHistory.java new file mode 100644 index 0000000..db03c60 --- /dev/null +++ b/src/main/java/qna/domain/DeleteHistory.java @@ -0,0 +1,55 @@ +package qna.domain; + +import javax.persistence.*; +import java.time.LocalDateTime; +import java.util.Objects; + +@Entity +public class DeleteHistory { + @Id + @GeneratedValue + private Long id; + + @Enumerated(EnumType.STRING) + private ContentType contentType; + + private Long contentId; + + @ManyToOne + @JoinColumn(foreignKey = @ForeignKey(name = "fk_deletehistory_to_user")) + private User deletedBy; + + private LocalDateTime createDate = LocalDateTime.now(); + + public DeleteHistory() { + } + + public DeleteHistory(ContentType contentType, Long contentId, User deletedBy, LocalDateTime createDate) { + this.contentType = contentType; + this.contentId = contentId; + this.deletedBy = deletedBy; + this.createDate = createDate; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + DeleteHistory that = (DeleteHistory) o; + return Objects.equals(id, that.id) && + contentType == that.contentType && + Objects.equals(contentId, that.contentId) && + Objects.equals(deletedBy, that.deletedBy); + } + + @Override + public int hashCode() { + return Objects.hash(id, contentType, contentId, deletedBy); + } + + @Override + public String toString() { + return "DeleteHistory [id=" + id + ", contentType=" + contentType + ", contentId=" + contentId + ", deletedBy=" + + deletedBy + ", createDate=" + createDate + "]"; + } +} diff --git a/src/main/java/qna/domain/DeleteHistoryRepository.java b/src/main/java/qna/domain/DeleteHistoryRepository.java new file mode 100644 index 0000000..28ad228 --- /dev/null +++ b/src/main/java/qna/domain/DeleteHistoryRepository.java @@ -0,0 +1,7 @@ +package qna.domain; + +import org.springframework.data.repository.CrudRepository; + +public interface DeleteHistoryRepository extends CrudRepository { + +} diff --git a/src/main/java/qna/domain/Question.java b/src/main/java/qna/domain/Question.java new file mode 100644 index 0000000..1e8bb11 --- /dev/null +++ b/src/main/java/qna/domain/Question.java @@ -0,0 +1,95 @@ +package qna.domain; + +import org.hibernate.annotations.Where; + +import javax.persistence.*; +import java.util.ArrayList; +import java.util.List; + +@Entity +public class Question extends AbstractEntity { + @Column(length = 100, nullable = false) + private String title; + + @Lob + private String contents; + + @ManyToOne + @JoinColumn(foreignKey = @ForeignKey(name = "fk_question_writer")) + private User writer; + + @OneToMany(mappedBy = "question", cascade = CascadeType.ALL) + @Where(clause = "deleted = false") + @OrderBy("id ASC") + private List answers = new ArrayList<>(); + + private boolean deleted = false; + + public Question() { + } + + public Question(String title, String contents) { + this.title = title; + this.contents = contents; + } + + public Question(long id, String title, String contents) { + super(id); + this.title = title; + this.contents = contents; + } + + public String getTitle() { + return title; + } + + public Question setTitle(String title) { + this.title = title; + return this; + } + + public String getContents() { + return contents; + } + + public Question setContents(String contents) { + this.contents = contents; + return this; + } + + public User getWriter() { + return writer; + } + + public Question writeBy(User loginUser) { + this.writer = loginUser; + return this; + } + + public void addAnswer(Answer answer) { + answer.toQuestion(this); + answers.add(answer); + } + + public boolean isOwner(User loginUser) { + return writer.equals(loginUser); + } + + public Question setDeleted(boolean deleted) { + this.deleted = deleted; + return this; + } + + public boolean isDeleted() { + return deleted; + } + + public List getAnswers() { + return answers; + } + + @Override + public String toString() { + return "Question [id=" + getId() + ", title=" + title + ", contents=" + contents + ", writer=" + writer + "]"; + } +} diff --git a/src/main/java/qna/domain/QuestionRepository.java b/src/main/java/qna/domain/QuestionRepository.java new file mode 100644 index 0000000..8a9dced --- /dev/null +++ b/src/main/java/qna/domain/QuestionRepository.java @@ -0,0 +1,12 @@ +package qna.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface QuestionRepository extends JpaRepository { + List findByDeletedFalse(); + + Optional findByIdAndDeletedFalse(Long id); +} diff --git a/src/main/java/qna/domain/User.java b/src/main/java/qna/domain/User.java new file mode 100755 index 0000000..bc0eb53 --- /dev/null +++ b/src/main/java/qna/domain/User.java @@ -0,0 +1,120 @@ +package qna.domain; + +import qna.UnAuthorizedException; + +import javax.persistence.Column; +import javax.persistence.Entity; +import java.util.Objects; + +@Entity +public class User extends AbstractEntity { + public static final GuestUser GUEST_USER = new GuestUser(); + + @Column(unique = true, nullable = false) + private String userId; + + @Column(nullable = false) + private String password; + + @Column(nullable = false) + private String name; + + private String email; + + public User() { + } + + public User(String userId, String password, String name, String email) { + this(null, userId, password, name, email); + } + + public User(Long id, String userId, String password, String name, String email) { + super(id); + this.userId = userId; + this.password = password; + this.name = name; + this.email = email; + } + + public String getUserId() { + return userId; + } + + public User setUserId(String userId) { + this.userId = userId; + return this; + } + + public String getPassword() { + return password; + } + + public User setPassword(String password) { + this.password = password; + return this; + } + + public String getName() { + return name; + } + + public User setName(String name) { + this.name = name; + return this; + } + + public String getEmail() { + return email; + } + + public User setEmail(String email) { + this.email = email; + return this; + } + + public void update(User loginUser, User target) { + if (!matchUserId(loginUser.getUserId())) { + throw new UnAuthorizedException(); + } + + if (!matchPassword(target.getPassword())) { + throw new UnAuthorizedException(); + } + + this.name = target.name; + this.email = target.email; + } + + private boolean matchUserId(String userId) { + return this.userId.equals(userId); + } + + public boolean matchPassword(String targetPassword) { + return password.equals(targetPassword); + } + + public boolean equalsNameAndEmail(User target) { + if (Objects.isNull(target)) { + return false; + } + + return name.equals(target.name) && + email.equals(target.email); + } + + public boolean isGuestUser() { + return false; + } + + private static class GuestUser extends User { + @Override + public boolean isGuestUser() { + return true; + } + } + + @Override + public String toString() { + return "User [userId=" + userId + ", password=" + password + ", name=" + name + ", email=" + email + "]"; + } +} diff --git a/src/main/java/qna/domain/UserRepository.java b/src/main/java/qna/domain/UserRepository.java new file mode 100755 index 0000000..14b3d67 --- /dev/null +++ b/src/main/java/qna/domain/UserRepository.java @@ -0,0 +1,9 @@ +package qna.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findByUserId(String userId); +} diff --git a/src/main/java/qna/service/DeleteHistoryService.java b/src/main/java/qna/service/DeleteHistoryService.java new file mode 100644 index 0000000..f962036 --- /dev/null +++ b/src/main/java/qna/service/DeleteHistoryService.java @@ -0,0 +1,26 @@ +package qna.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import qna.domain.DeleteHistory; +import qna.domain.DeleteHistoryRepository; + +import javax.annotation.Resource; +import java.util.List; + +@Service("deleteHistoryService") +public class DeleteHistoryService { + @Resource(name = "deleteHistoryRepository") + private DeleteHistoryRepository deleteHistoryRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void saveAll(List deleteHistories) { + deleteHistoryRepository.saveAll(deleteHistories); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void save(DeleteHistory deleteHistory) { + deleteHistoryRepository.save(deleteHistory); + } +} \ No newline at end of file diff --git a/src/main/java/qna/service/QnAService.java b/src/main/java/qna/service/QnAService.java new file mode 100644 index 0000000..66821cd --- /dev/null +++ b/src/main/java/qna/service/QnAService.java @@ -0,0 +1,58 @@ +package qna.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import qna.CannotDeleteException; +import qna.NotFoundException; +import qna.domain.*; + +import javax.annotation.Resource; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Service("qnaService") +public class QnAService { + private static final Logger log = LoggerFactory.getLogger(QnAService.class); + + @Resource(name = "questionRepository") + private QuestionRepository questionRepository; + + @Resource(name = "answerRepository") + private AnswerRepository answerRepository; + + @Resource(name = "deleteHistoryService") + private DeleteHistoryService deleteHistoryService; + + @Transactional(readOnly = true) + public Question findQuestionById(Long id) { + return questionRepository.findByIdAndDeletedFalse(id) + .orElseThrow(NotFoundException::new); + } + + @Transactional + public void deleteQuestion(User loginUser, long questionId) throws CannotDeleteException { + Question question = findQuestionById(questionId); + if (!question.isOwner(loginUser)) { + throw new CannotDeleteException("질문을 삭제할 권한이 없습니다."); + } + + List answers = question.getAnswers(); + for (Answer answer : answers) { + if (!answer.isOwner(loginUser)) { + throw new CannotDeleteException("다른 사람이 쓴 답변이 있어 삭제할 수 없습니다."); + } + } + + List deleteHistories = new ArrayList<>(); + question.setDeleted(true); + deleteHistories.add(new DeleteHistory(ContentType.QUESTION, questionId, question.getWriter(), LocalDateTime.now())); + for (Answer answer : answers) { + answer.setDeleted(true); + deleteHistories.add(new DeleteHistory(ContentType.ANSWER, answer.getId(), answer.getWriter(), LocalDateTime.now())); + } + deleteHistoryService.saveAll(deleteHistories); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100755 index 0000000..befbb0f --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,11 @@ +spring.h2.console.enabled=true + +spring.datasource.url=jdbc:h2:mem://localhost/~/java-qna-atdd;MVCC=TRUE;DB_CLOSE_ON_EXIT=FALSE + +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect + +logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE + +server.servlet.session.tracking-modes=cookie \ No newline at end of file diff --git a/src/test/java/bowling/empty.txt b/src/test/java/bowling/empty.txt new file mode 100644 index 0000000..e69de29 diff --git a/src/test/java/qna/domain/AnswerTest.java b/src/test/java/qna/domain/AnswerTest.java new file mode 100644 index 0000000..d858181 --- /dev/null +++ b/src/test/java/qna/domain/AnswerTest.java @@ -0,0 +1,6 @@ +package qna.domain; + +public class AnswerTest { + public static final Answer A1 = new Answer(UserTest.JAVAJIGI, QuestionTest.Q1, "Answers Contents1"); + public static final Answer A2 = new Answer(UserTest.SANJIGI, QuestionTest.Q1, "Answers Contents2"); +} diff --git a/src/test/java/qna/domain/QuestionTest.java b/src/test/java/qna/domain/QuestionTest.java new file mode 100644 index 0000000..b48c9a2 --- /dev/null +++ b/src/test/java/qna/domain/QuestionTest.java @@ -0,0 +1,6 @@ +package qna.domain; + +public class QuestionTest { + public static final Question Q1 = new Question("title1", "contents1").writeBy(UserTest.JAVAJIGI); + public static final Question Q2 = new Question("title2", "contents2").writeBy(UserTest.SANJIGI); +} diff --git a/src/test/java/qna/domain/UserTest.java b/src/test/java/qna/domain/UserTest.java new file mode 100644 index 0000000..4f0936e --- /dev/null +++ b/src/test/java/qna/domain/UserTest.java @@ -0,0 +1,6 @@ +package qna.domain; + +public class UserTest { + public static final User JAVAJIGI = new User(1L, "javajigi", "password", "name", "javajigi@slipp.net"); + public static final User SANJIGI = new User(2L, "sanjigi", "password", "name", "sanjigi@slipp.net"); +} diff --git a/src/test/java/qna/service/QnaServiceTest.java b/src/test/java/qna/service/QnaServiceTest.java new file mode 100644 index 0000000..c601a79 --- /dev/null +++ b/src/test/java/qna/service/QnaServiceTest.java @@ -0,0 +1,89 @@ +package qna.service; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import qna.CannotDeleteException; +import qna.domain.*; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class QnaServiceTest { + @Mock + private QuestionRepository questionRepository; + + @Mock + private DeleteHistoryService deleteHistoryService; + + @InjectMocks + private QnAService qnAService; + + private Question question; + private Answer answer; + + @Before + public void setUp() throws Exception { + question = new Question(1L, "title1", "contents1").writeBy(UserTest.JAVAJIGI); + answer = new Answer(11L, UserTest.JAVAJIGI, QuestionTest.Q1, "Answers Contents1"); + question.addAnswer(answer); + } + + @Test + public void delete_성공() throws Exception { + when(questionRepository.findByIdAndDeletedFalse(question.getId())).thenReturn(Optional.of(question)); + + assertThat(question.isDeleted()).isFalse(); + qnAService.deleteQuestion(UserTest.JAVAJIGI, question.getId()); + + assertThat(question.isDeleted()).isTrue(); + verifyDeleteHistories(); + } + + @Test + public void delete_다른_사람이_쓴_글() throws Exception { + when(questionRepository.findByIdAndDeletedFalse(question.getId())).thenReturn(Optional.of(question)); + + assertThatThrownBy(() -> { + qnAService.deleteQuestion(UserTest.SANJIGI, question.getId()); + }).isInstanceOf(CannotDeleteException.class); + } + + @Test + public void delete_성공_질문자_답변자_같음() throws Exception { + when(questionRepository.findByIdAndDeletedFalse(question.getId())).thenReturn(Optional.of(question)); + + qnAService.deleteQuestion(UserTest.JAVAJIGI, question.getId()); + + assertThat(question.isDeleted()).isTrue(); + assertThat(answer.isDeleted()).isTrue(); + verifyDeleteHistories(); + } + + @Test + public void delete_답변_중_다른_사람이_쓴_글() throws Exception { + when(questionRepository.findByIdAndDeletedFalse(question.getId())).thenReturn(Optional.of(question)); + + assertThatThrownBy(() -> { + qnAService.deleteQuestion(UserTest.SANJIGI, question.getId()); + }).isInstanceOf(CannotDeleteException.class); + } + + private void verifyDeleteHistories() { + List deleteHistories = Arrays.asList( + new DeleteHistory(ContentType.QUESTION, question.getId(), question.getWriter(), LocalDateTime.now()), + new DeleteHistory(ContentType.ANSWER, answer.getId(), answer.getWriter(), LocalDateTime.now())); + verify(deleteHistoryService).saveAll(deleteHistories); + } +}