diff --git a/.gitignore b/.gitignore
new file mode 100644
index 000000000..51fadb48f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,205 @@
+# Created by https://www.toptal.com/developers/gitignore/api/intellij+all,gradle,java,macos,windows
+# Edit at https://www.toptal.com/developers/gitignore?templates=intellij+all,gradle,java,macos,windows
+
+### Intellij+all ###
+# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
+# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
+
+# User-specific stuff
+.idea/**/workspace.xml
+.idea/**/tasks.xml
+.idea/**/usage.statistics.xml
+.idea/**/dictionaries
+.idea/**/shelf
+
+# AWS User-specific
+.idea/**/aws.xml
+
+# Generated files
+.idea/**/contentModel.xml
+
+# Sensitive or high-churn files
+.idea/**/dataSources/
+.idea/**/dataSources.ids
+.idea/**/dataSources.local.xml
+.idea/**/sqlDataSources.xml
+.idea/**/dynamic.xml
+.idea/**/uiDesigner.xml
+.idea/**/dbnavigator.xml
+
+# Gradle
+.idea/**/gradle.xml
+.idea/**/libraries
+
+# Gradle and Maven with auto-import
+# When using Gradle or Maven with auto-import, you should exclude module files,
+# since they will be recreated, and may cause churn. Uncomment if using
+# auto-import.
+# .idea/artifacts
+# .idea/compiler.xml
+# .idea/jarRepositories.xml
+# .idea/modules.xml
+# .idea/*.iml
+# .idea/modules
+# *.iml
+# *.ipr
+
+# CMake
+cmake-build-*/
+
+# Mongo Explorer plugin
+.idea/**/mongoSettings.xml
+
+# File-based project format
+*.iws
+
+# IntelliJ
+out/
+Help.md
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Cursive Clojure plugin
+.idea/replstate.xml
+
+# SonarLint plugin
+.idea/sonarlint/
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+
+# Editor-based Rest Client
+.idea/httpRequests
+
+# Android studio 3.1+ serialized cache file
+.idea/caches/build_file_checksums.ser
+
+### Intellij+all Patch ###
+# Ignore everything but code style settings and run configurations
+# that are supposed to be shared within teams.
+
+.idea/*
+
+!.idea/codeStyles
+!.idea/runConfigurations
+
+### Java ###
+# Compiled class file
+*.class
+
+# Log file
+*.log
+
+# BlueJ files
+*.ctxt
+
+# Mobile Tools for Java (J2ME)
+.mtj.tmp/
+
+# Package Files #
+*.jar
+*.war
+*.nar
+*.ear
+*.zip
+*.tar.gz
+*.rar
+
+# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
+hs_err_pid*
+replay_pid*
+
+### macOS ###
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+### macOS Patch ###
+# iCloud generated files
+*.icloud
+
+### Windows ###
+# Windows thumbnail cache files
+Thumbs.db
+Thumbs.db:encryptable
+ehthumbs.db
+ehthumbs_vista.db
+
+# Dump file
+*.stackdump
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
+### Gradle ###
+.gradle
+**/build/
+!src/**/build/
+
+# Ignore Gradle GUI config
+gradle-app.setting
+
+# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
+!gradle-wrapper.jar
+
+# Avoid ignore Gradle wrappper properties
+!gradle-wrapper.properties
+
+# Cache of project
+.gradletasknamecache
+
+# Eclipse Gradle plugin generated files
+# Eclipse Core
+.project
+# JDT-specific (Eclipse Java Development Tools)
+.classpath
+
+### Gradle Patch ###
+# Java heap dump
+*.hprof
+
+# End of https://www.toptal.com/developers/gitignore/api/intellij+all,gradle,java,macos,windows
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 000000000..1c011ee4a
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,5 @@
+FROM mysql:latest
+
+ADD schema.sql /docker-entrypoint-initdb.d/
+
+EXPOSE 3306
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 000000000..57c98ca85
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,68 @@
+plugins {
+ id 'java'
+ id 'org.springframework.boot' version '3.1.5'
+ id 'io.spring.dependency-management' version '1.1.3'
+ id 'org.asciidoctor.jvm.convert' version '3.3.2'
+}
+
+group = 'com.devcourse'
+version = '0.0.1-SNAPSHOT'
+
+java {
+ sourceCompatibility = '17'
+}
+
+configurations {
+ compileOnly {
+ extendsFrom annotationProcessor
+ }
+ asciidoctorExt
+}
+
+ext {
+ snippetsDir = file('build/generated-snippets')
+}
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+ implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
+ implementation 'org.springframework.boot:spring-boot-starter-validation'
+ compileOnly 'org.projectlombok:lombok'
+ runtimeOnly 'com.mysql:mysql-connector-j'
+ annotationProcessor 'org.projectlombok:lombok'
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testImplementation 'com.github.javafaker:javafaker:1.0.2'
+ testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
+ asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
+}
+
+tasks.named('bootBuildImage') {
+ builder = 'paketobuildpacks/builder-jammy-base:latest'
+}
+
+tasks.named('test') {
+ useJUnitPlatform()
+ outputs.dir snippetsDir
+}
+
+asciidoctor {
+ inputs.dir snippetsDir
+ configurations 'asciidoctorExt'
+ sources {
+ include 'index.adoc'
+ }
+ dependsOn test
+ outputDir 'src/docs/asciidoc'
+}
+
+bootJar {
+ dependsOn asciidoctor
+ copy {
+ from "${asciidoctor.outputDir}"
+ into 'build/static/docs'
+ }
+}
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 000000000..7f93135c4
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 000000000..3fa8f862f
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100644
index 000000000..1aa94a426
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,249 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original 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 POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# 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 ;; #(
+ MSYS* | 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
+ if ! command -v java >/dev/null 2>&1
+ then
+ 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
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# 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"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 000000000..93e3f59f1
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,92 @@
+@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=.
+@rem This is normally unused
+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% equ 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% equ 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!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/schema.sql b/schema.sql
new file mode 100644
index 000000000..43bb51222
--- /dev/null
+++ b/schema.sql
@@ -0,0 +1,22 @@
+create table users
+(
+ id bigint not null auto_increment primary key,
+ age integer not null,
+ hobby varchar(255),
+ name varchar(255) not null,
+ created_at datetime(6) not null,
+ created_by varchar(255),
+ updated_at datetime(6) not null
+);
+
+create table posts
+(
+ id bigint not null auto_increment primary key,
+ title varchar(255) not null,
+ content tinytext not null,
+ created_at datetime(6) not null,
+ created_by varchar(255),
+ updated_at datetime(6) not null,
+ user_id bigint not null,
+ foreign Key (user_id) references users (id) on delete restrict
+);
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 000000000..d810aa9d4
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = 'springboot-board-jpa-HI'
diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc
new file mode 100644
index 000000000..4f6cc5865
--- /dev/null
+++ b/src/docs/asciidoc/index.adoc
@@ -0,0 +1,161 @@
+ifndef::snippets[]
+:snippets: ../../../build/generated-snippets
+endif::[]
+
+= JPA 게시판 API 문서
+Doc Writers - 김현우 송인재
+:doctype: book
+:icons: font
+:source-highlighter: highlightjs
+:toc: left
+:toclevels: 2
+:sectlinks:
+
+= 유저
+
+'''
+== 유저 조회
+
+=== `*GET /users*`
+
+NOTE: 유저가 없을 경우
+
+==== Request
+
+include::{snippets}/user-find-all-no-content/http-request.adoc[]
+
+==== Response
+
+include::{snippets}/user-find-all-no-content/http-response.adoc[]
+
+TIP: 유저 조회 성공
+
+==== Request
+
+include::{snippets}/user-find-all/http-request.adoc[]
+
+==== Response
+
+include::{snippets}/user-find-all/http-response.adoc[]
+include::{snippets}/user-find-all/response-fields.adoc[]
+
+== 유저 생성
+
+=== `*POST /users*`
+
+==== Request
+
+include::{snippets}/user-create/http-request.adoc[]
+
+==== Response
+
+include::{snippets}/user-create/http-response.adoc[]
+include::{snippets}/user-create/response-fields.adoc[]
+
+= 포스트
+
+'''
+== 포스트 다건 페이지 조회
+
+=== `*GET /posts*`
+
+NOTE: 포스트가 없을 경우
+
+==== Request
+
+include::{snippets}/post-page-no-content/http-request.adoc[]
+
+==== Response
+
+include::{snippets}/post-page-no-content/http-response.adoc[]
+
+TIP: 포스트 페이지 조회 성공
+
+==== Request
+
+include::{snippets}/post-page/http-request.adoc[]
+
+==== Response
+
+include::{snippets}/post-page/http-response.adoc[]
+include::{snippets}/post-page/response-fields.adoc[]
+
+== 포스트 생성
+
+=== `*POST /posts*`
+
+TIP: 포스트 생성 성공
+
+==== Request
+
+include::{snippets}/post-create/http-request.adoc[]
+include::{snippets}/post-create/request-fields.adoc[]
+
+==== Response
+
+include::{snippets}/post-create/http-response.adoc[]
+include::{snippets}/post-create/response-fields.adoc[]
+
+WARNING: 존재하지 않는 유저의 포스트를 생성하는 경우
+
+==== Request
+
+include::{snippets}/post-create-not-exist-user/http-request.adoc[]
+
+==== Response
+
+include::{snippets}/post-create-not-exist-user/http-response.adoc[]
+include::{snippets}/post-create-not-exist-user/response-fields.adoc[]
+
+== 포스트 단건 조회
+
+=== `*GET /posts/\{id}*`
+
+TIP: 포스트 상세 정보 조회 성공
+
+==== Request
+
+include::{snippets}/post-find-single/http-request.adoc[]
+
+==== Response
+
+include::{snippets}/post-find-single/http-response.adoc[]
+include::{snippets}/post-find-single/response-fields.adoc[]
+
+WARNING: 포스트가 존재하지 않는 경우
+
+==== Request
+
+include::{snippets}/post-find-single-not-exist/http-request.adoc[]
+
+==== Response
+
+include::{snippets}/post-find-single-not-exist/http-response.adoc[]
+include::{snippets}/post-find-single-not-exist/response-fields.adoc[]
+
+== 포스트 수정
+
+=== `*PUT /posts/\{id}*`
+
+TIP: 포스트 정보 수정 성공
+
+==== Request
+
+include::{snippets}/post-update/http-request.adoc[]
+include::{snippets}/post-update/request-fields.adoc[]
+
+==== Response
+
+include::{snippets}/post-update/http-response.adoc[]
+include::{snippets}/post-update/response-fields.adoc[]
+
+WARNING: 포스트가 존재하지 않는 경우
+
+==== Request
+
+include::{snippets}/post-update-not-exist/http-request.adoc[]
+
+==== Response
+
+include::{snippets}/post-update-not-exist/http-response.adoc[]
+include::{snippets}/post-update-not-exist/response-fields.adoc[]
diff --git a/src/docs/asciidoc/index.html b/src/docs/asciidoc/index.html
new file mode 100644
index 000000000..701282f92
--- /dev/null
+++ b/src/docs/asciidoc/index.html
@@ -0,0 +1,1420 @@
+
+
+
+
+
+
+
+
+JPA 게시판 API 문서
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+유저가 없을 경우
+
+
+
+
+
+
+
+
+
GET /api/v1/users HTTP/1.1
+Host: localhost:8080
+
+
+
+
+
+
+
+
HTTP/1.1 204 No Content
+
+
+
+
+
+
+
+
+
+유저 조회 성공
+
+
+
+
+
+
+
+
+
+
GET /api/v1/users HTTP/1.1
+Host: localhost:8080
+
+
+
+
+
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 246
+
+[ {
+ "id" : 7,
+ "name" : "Tamra",
+ "age" : 1,
+ "hobby" : "League of Legends",
+ "createdAt" : "2023-11-24T23:41:58.747034"
+}, {
+ "id" : 9,
+ "name" : "Keven",
+ "age" : 41,
+ "hobby" : "PUBG",
+ "createdAt" : "2023-11-24T23:41:58.747243"
+} ]
+
+
+
+
+
+
+
+
+
+
+Path
+Type
+Description
+
+
+
+
+[].id
+Number
+Id
+
+
+[].name
+String
+Name
+
+
+[].age
+Number
+Age
+
+
+[].hobby
+String
+Hobby
+
+
+[].createdAt
+String
+Creation Datetime
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
POST /api/v1/users HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 75
+Host: localhost:8080
+
+{
+ "name" : "Cliff",
+ "age" : 47,
+ "hobby" : "Super Smash Bros. Melee"
+}
+
+
+
+
+
+
+
+
HTTP/1.1 201 Created
+Content-Type: application/json
+Content-Length: 133
+
+{
+ "id" : 8,
+ "name" : "Cliff",
+ "age" : 47,
+ "hobby" : "Super Smash Bros. Melee",
+ "createdAt" : "2023-11-24T23:41:58.783377"
+}
+
+
+
+
+
+
+
+
+
+
+Path
+Type
+Description
+
+
+
+
+id
+Number
+Id
+
+
+name
+String
+Name
+
+
+age
+Number
+Age
+
+
+hobby
+String
+Hobby
+
+
+createdAt
+String
+Creation Datetime
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+포스트가 없을 경우
+
+
+
+
+
+
+
+
+
GET /api/v1/posts HTTP/1.1
+Host: localhost:8080
+
+
+
+
+
+
+
+
HTTP/1.1 204 No Content
+
+
+
+
+
+
+
+
+
+포스트 페이지 조회 성공
+
+
+
+
+
+
+
+
+
+
GET /api/v1/posts?page=3&size=10 HTTP/1.1
+Host: localhost:8080
+
+
+
+
+
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 1260
+
+{
+ "isEmpty" : false,
+ "totalPages" : 4,
+ "totalElements" : 35,
+ "content" : [ {
+ "id" : 1,
+ "title" : "To Your Scattered Bodies Go",
+ "content" : "And it must follow, as the night the day, thou canst not then be false to any man.",
+ "authorName" : "Garnet",
+ "createdAt" : "2023-11-24T23:41:58.153386"
+ }, {
+ "id" : 2,
+ "title" : "The Grapes of Wrath",
+ "content" : "This above all: to thine own self be true.",
+ "authorName" : "Bernardo",
+ "createdAt" : "2023-11-24T23:41:58.15365"
+ }, {
+ "id" : 3,
+ "title" : "A Catskill Eagle",
+ "content" : "Doubt that the sun doth move, doubt truth to be a liar, but never doubt I love.",
+ "authorName" : "Graciela",
+ "createdAt" : "2023-11-24T23:41:58.153812"
+ }, {
+ "id" : 4,
+ "title" : "Dying of the Light",
+ "content" : "Neither a borrower nor a lender be; For loan oft loses both itself and friend, and borrowing dulls the edge of husbandry.",
+ "authorName" : "Winfred",
+ "createdAt" : "2023-11-24T23:41:58.153974"
+ }, {
+ "id" : 5,
+ "title" : "An Evil Cradling",
+ "content" : "Doubt that the sun doth move, doubt truth to be a liar, but never doubt I love.",
+ "authorName" : "Ula",
+ "createdAt" : "2023-11-24T23:41:58.154138"
+ } ]
+}
+
+
+
+
+
+
+
+
+
+
+Path
+Type
+Description
+
+
+
+
+isEmpty
+Boolean
+True if no post is found
+
+
+totalPages
+Number
+Total number of pages
+
+
+totalElements
+Number
+Total number of all posts
+
+
+content[].id
+Number
+Post Id
+
+
+content[].title
+String
+Title
+
+
+content[].content
+String
+Content
+
+
+content[].authorName
+String
+Author Name
+
+
+content[].createdAt
+String
+Creation Datetime
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+포스트 생성 성공
+
+
+
+
+
+
+
+
+
POST /api/v1/posts HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 95
+Host: localhost:8080
+
+{
+ "title" : "To a God Unknown",
+ "content" : "Brevity is the soul of wit.",
+ "userId" : 1
+}
+
+
+
+
+
+
+
+
+
+
+Path
+Type
+Description
+
+
+
+
+title
+String
+Title
+
+
+content
+String
+Content
+
+
+userId
+Number
+User ID
+
+
+
+
+
+
+
+
+
HTTP/1.1 201 Created
+Content-Type: application/json
+Content-Length: 167
+
+{
+ "id" : 2,
+ "title" : "To a God Unknown",
+ "content" : "Brevity is the soul of wit.",
+ "authorName" : "Jeannetta",
+ "createdAt" : "2023-11-24T23:41:58.249385"
+}
+
+
+
+
+
+
+
+
+
+
+Path
+Type
+Description
+
+
+
+
+id
+Number
+ID
+
+
+title
+String
+Title
+
+
+content
+String
+Content
+
+
+authorName
+String
+Author Name
+
+
+createdAt
+String
+Creation Datetime
+
+
+
+
+
+
+
+
+
+
+존재하지 않는 유저의 포스트를 생성하는 경우
+
+
+
+
+
+
+
+
+
+
POST /api/v1/posts HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 117
+Host: localhost:8080
+
+{
+ "title" : "Ring of Bright Water",
+ "content" : "Rich gifts wax poor when givers prove unkind.",
+ "userId" : 2
+}
+
+
+
+
+
+
+
+
HTTP/1.1 400 Bad Request
+Content-Type: application/json
+Content-Length: 57
+
+{
+ "message" : "존재하지 않는 유저 입니다."
+}
+
+
+
+
+
+
+
+
+
+
+Path
+Type
+Description
+
+
+
+
+message
+String
+Error Message
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+포스트 상세 정보 조회 성공
+
+
+
+
+
+
+
+
+
GET /api/v1/posts/3 HTTP/1.1
+Host: localhost:8080
+
+
+
+
+
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 216
+
+{
+ "id" : 3,
+ "title" : "An Evil Cradling",
+ "content" : "Now is the winter of our discontent.",
+ "authorName" : "Pia",
+ "createdAt" : "2023-11-24T23:41:58.301576",
+ "updatedAt" : "2023-11-24T23:41:58.301595"
+}
+
+
+
+
+
+
+
+
+
+
+Path
+Type
+Description
+
+
+
+
+id
+Number
+ID
+
+
+title
+String
+Title
+
+
+content
+String
+Content
+
+
+authorName
+String
+Author Name
+
+
+createdAt
+String
+Creation Datetime
+
+
+updatedAt
+String
+Last Update Datetime
+
+
+
+
+
+
+
+
+
+
+포스트가 존재하지 않는 경우
+
+
+
+
+
+
+
+
+
+
GET /api/v1/posts/2 HTTP/1.1
+Host: localhost:8080
+
+
+
+
+
+
+
+
HTTP/1.1 400 Bad Request
+Content-Type: application/json
+Content-Length: 60
+
+{
+ "message" : "존재하지 않는 게시글 입니다."
+}
+
+
+
+
+
+
+
+
+
+
+Path
+Type
+Description
+
+
+
+
+message
+String
+Error Message
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+포스트 정보 수정 성공
+
+
+
+
+
+
+
+
+
PUT /api/v1/posts/8 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 91
+Host: localhost:8080
+
+{
+ "title" : "Mr Standfast",
+ "content" : "A little more than kin, and less than kind."
+}
+
+
+
+
+
+
+
+
+
+
+Path
+Type
+Description
+
+
+
+
+title
+String
+Title
+
+
+content
+String
+Content
+
+
+
+
+
+
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 225
+
+{
+ "id" : 8,
+ "title" : "Mr Standfast",
+ "content" : "A little more than kin, and less than kind.",
+ "authorName" : "Granville",
+ "createdAt" : "2023-11-24T23:41:58.374937",
+ "updatedAt" : "2023-11-24T23:41:58.374968"
+}
+
+
+
+
+
+
+
+
+
+
+Path
+Type
+Description
+
+
+
+
+id
+Number
+ID
+
+
+title
+String
+Title
+
+
+content
+String
+Content
+
+
+authorName
+String
+Author Name
+
+
+createdAt
+String
+Creation Datetime
+
+
+updatedAt
+String
+Last Update Datetime
+
+
+
+
+
+
+
+
+
+
+포스트가 존재하지 않는 경우
+
+
+
+
+
+
+
+
+
+
PUT /api/v1/posts/3 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 296
+Host: localhost:8080
+
+{
+ "title" : "The Heart Is a Lonely Hunter",
+ "content" : "What a piece of work is man! how noble in reason! how infinite in faculty! in form and moving how express and admirable! in action how like an angel! in apprehension how like a god! the beauty of the world, the paragon of animals! ."
+}
+
+
+
+
+
+
+
+
HTTP/1.1 400 Bad Request
+Content-Type: application/json
+Content-Length: 60
+
+{
+ "message" : "존재하지 않는 게시글 입니다."
+}
+
+
+
+
+
+
+
+
+
+
+Path
+Type
+Description
+
+
+
+
+message
+String
+Error Message
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/java/com/devcourse/springbootboardjpahi/SpringbootBoardJpaHiApplication.java b/src/main/java/com/devcourse/springbootboardjpahi/SpringbootBoardJpaHiApplication.java
new file mode 100644
index 000000000..03c65a2fe
--- /dev/null
+++ b/src/main/java/com/devcourse/springbootboardjpahi/SpringbootBoardJpaHiApplication.java
@@ -0,0 +1,13 @@
+package com.devcourse.springbootboardjpahi;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class SpringbootBoardJpaHiApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(SpringbootBoardJpaHiApplication.class, args);
+ }
+
+}
diff --git a/src/main/java/com/devcourse/springbootboardjpahi/advice/ControllerAdvice.java b/src/main/java/com/devcourse/springbootboardjpahi/advice/ControllerAdvice.java
new file mode 100644
index 000000000..d288f76f6
--- /dev/null
+++ b/src/main/java/com/devcourse/springbootboardjpahi/advice/ControllerAdvice.java
@@ -0,0 +1,39 @@
+package com.devcourse.springbootboardjpahi.advice;
+
+import com.devcourse.springbootboardjpahi.message.ControllerAdviceExceptionMessage;
+import java.util.List;
+import java.util.NoSuchElementException;
+import java.util.Objects;
+import org.springframework.context.support.DefaultMessageSourceResolvable;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.FieldError;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+@RestControllerAdvice
+public class ControllerAdvice {
+
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
+ List errors = e.getFieldErrors();
+ String message = errors.stream()
+ .map(DefaultMessageSourceResolvable::getDefaultMessage)
+ .filter(Objects::nonNull)
+ .findFirst()
+ .orElse(ControllerAdviceExceptionMessage.INVALID_ARGUMENT);
+
+ ErrorResponse errorResponse = new ErrorResponse(message);
+
+ return ResponseEntity.badRequest()
+ .body(errorResponse);
+ }
+
+ @ExceptionHandler(NoSuchElementException.class)
+ public ResponseEntity handleNoSuchElementException(NoSuchElementException e) {
+ ErrorResponse errorResponse = new ErrorResponse(e.getMessage());
+
+ return ResponseEntity.badRequest()
+ .body(errorResponse);
+ }
+}
diff --git a/src/main/java/com/devcourse/springbootboardjpahi/advice/ErrorResponse.java b/src/main/java/com/devcourse/springbootboardjpahi/advice/ErrorResponse.java
new file mode 100644
index 000000000..8d99c1fd3
--- /dev/null
+++ b/src/main/java/com/devcourse/springbootboardjpahi/advice/ErrorResponse.java
@@ -0,0 +1,4 @@
+package com.devcourse.springbootboardjpahi.advice;
+
+public record ErrorResponse(String message) {
+}
diff --git a/src/main/java/com/devcourse/springbootboardjpahi/controller/PostController.java b/src/main/java/com/devcourse/springbootboardjpahi/controller/PostController.java
new file mode 100644
index 000000000..11d5e38c8
--- /dev/null
+++ b/src/main/java/com/devcourse/springbootboardjpahi/controller/PostController.java
@@ -0,0 +1,63 @@
+package com.devcourse.springbootboardjpahi.controller;
+
+import com.devcourse.springbootboardjpahi.dto.CreatePostRequest;
+import com.devcourse.springbootboardjpahi.dto.PageResponse;
+import com.devcourse.springbootboardjpahi.dto.PostDetailResponse;
+import com.devcourse.springbootboardjpahi.dto.PostResponse;
+import com.devcourse.springbootboardjpahi.dto.UpdatePostRequest;
+import com.devcourse.springbootboardjpahi.service.PostService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Pageable;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/v1/posts")
+@RequiredArgsConstructor
+public class PostController {
+
+ private final PostService postService;
+
+ @GetMapping
+ public ResponseEntity> find(Pageable pageable) {
+ PageResponse page = postService.getPage(pageable);
+
+ if (page.isEmpty()) {
+ return ResponseEntity.noContent()
+ .build();
+ }
+
+ return ResponseEntity.ok(page);
+ }
+
+ @PostMapping
+ public ResponseEntity create(@Valid @RequestBody CreatePostRequest request) {
+ PostResponse post = postService.create(request);
+
+ return ResponseEntity.status(HttpStatus.CREATED)
+ .body(post);
+ }
+
+ @GetMapping("/{id}")
+ public ResponseEntity findById(@PathVariable Long id) {
+ PostDetailResponse postDetailResponse = postService.findById(id);
+
+ return ResponseEntity.ok(postDetailResponse);
+ }
+
+ @PutMapping("/{id}")
+ public ResponseEntity updateById(@PathVariable Long id,
+ @Valid @RequestBody UpdatePostRequest request) {
+ PostDetailResponse postDetailResponse = postService.updateById(id, request);
+
+ return ResponseEntity.ok(postDetailResponse);
+ }
+}
diff --git a/src/main/java/com/devcourse/springbootboardjpahi/controller/UserController.java b/src/main/java/com/devcourse/springbootboardjpahi/controller/UserController.java
new file mode 100644
index 000000000..71f259ef5
--- /dev/null
+++ b/src/main/java/com/devcourse/springbootboardjpahi/controller/UserController.java
@@ -0,0 +1,43 @@
+package com.devcourse.springbootboardjpahi.controller;
+
+import com.devcourse.springbootboardjpahi.dto.CreateUserRequest;
+import com.devcourse.springbootboardjpahi.dto.UserResponse;
+import com.devcourse.springbootboardjpahi.service.UserService;
+import jakarta.validation.Valid;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+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.RestController;
+
+@RestController
+@RequestMapping("/api/v1/users")
+@RequiredArgsConstructor
+public class UserController {
+
+ private final UserService userService;
+
+ @GetMapping
+ public ResponseEntity> findAll() {
+ List users = userService.findAll();
+
+ if (users.isEmpty()) {
+ return ResponseEntity.noContent()
+ .build();
+ }
+
+ return ResponseEntity.ok(users);
+ }
+
+ @PostMapping
+ public ResponseEntity create(@Valid @RequestBody CreateUserRequest request) {
+ UserResponse user = userService.create(request);
+
+ return ResponseEntity.status(HttpStatus.CREATED)
+ .body(user);
+ }
+}
diff --git a/src/main/java/com/devcourse/springbootboardjpahi/domain/BaseEntity.java b/src/main/java/com/devcourse/springbootboardjpahi/domain/BaseEntity.java
new file mode 100644
index 000000000..f573fbe38
--- /dev/null
+++ b/src/main/java/com/devcourse/springbootboardjpahi/domain/BaseEntity.java
@@ -0,0 +1,24 @@
+package com.devcourse.springbootboardjpahi.domain;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.MappedSuperclass;
+import java.time.LocalDateTime;
+import lombok.Getter;
+import org.hibernate.annotations.CreationTimestamp;
+import org.hibernate.annotations.UpdateTimestamp;
+
+@Getter
+@MappedSuperclass
+public class BaseEntity {
+
+ @Column(name = "created_at", nullable = false)
+ @CreationTimestamp
+ private LocalDateTime createdAt;
+
+ @Column(name = "updated_at", nullable = false)
+ @UpdateTimestamp
+ private LocalDateTime updatedAt;
+
+ @Column(name = "created_by")
+ private String createdBy;
+}
diff --git a/src/main/java/com/devcourse/springbootboardjpahi/domain/Post.java b/src/main/java/com/devcourse/springbootboardjpahi/domain/Post.java
new file mode 100644
index 000000000..42666aeb0
--- /dev/null
+++ b/src/main/java/com/devcourse/springbootboardjpahi/domain/Post.java
@@ -0,0 +1,48 @@
+package com.devcourse.springbootboardjpahi.domain;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.Lob;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "posts")
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class Post extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private long id;
+
+ @Column(name = "title", nullable = false)
+ private String title;
+
+ @Column(name = "content", nullable = false)
+ @Lob
+ private String content;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id", referencedColumnName = "id")
+ private User user;
+
+ public void updateTitle(String title) {
+ this.title = title;
+ }
+
+ public void updateContent(String content) {
+ this.content = content;
+ }
+}
diff --git a/src/main/java/com/devcourse/springbootboardjpahi/domain/User.java b/src/main/java/com/devcourse/springbootboardjpahi/domain/User.java
new file mode 100644
index 000000000..b6910a6d8
--- /dev/null
+++ b/src/main/java/com/devcourse/springbootboardjpahi/domain/User.java
@@ -0,0 +1,39 @@
+package com.devcourse.springbootboardjpahi.domain;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.OneToMany;
+import jakarta.persistence.Table;
+import java.util.List;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "users")
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class User extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private long id;
+
+ @Column(name = "name", nullable = false)
+ private String name;
+
+ @Column(name = "age", nullable = false)
+ private Integer age;
+
+ @Column(name = "hobby")
+ private String hobby;
+
+ @OneToMany(mappedBy = "user")
+ private List posts;
+}
diff --git a/src/main/java/com/devcourse/springbootboardjpahi/dto/CreatePostRequest.java b/src/main/java/com/devcourse/springbootboardjpahi/dto/CreatePostRequest.java
new file mode 100644
index 000000000..75deae479
--- /dev/null
+++ b/src/main/java/com/devcourse/springbootboardjpahi/dto/CreatePostRequest.java
@@ -0,0 +1,17 @@
+package com.devcourse.springbootboardjpahi.dto;
+
+import com.devcourse.springbootboardjpahi.message.PostExceptionMessage;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Positive;
+
+public record CreatePostRequest(
+ @NotBlank(message = PostExceptionMessage.BLANK_TITLE)
+ String title,
+ @NotNull(message = PostExceptionMessage.NULL_CONTENT)
+ String content,
+ @Positive(message = PostExceptionMessage.INVALID_USER_ID)
+ Long userId
+) {
+
+}
diff --git a/src/main/java/com/devcourse/springbootboardjpahi/dto/CreateUserRequest.java b/src/main/java/com/devcourse/springbootboardjpahi/dto/CreateUserRequest.java
new file mode 100644
index 000000000..df1e56b45
--- /dev/null
+++ b/src/main/java/com/devcourse/springbootboardjpahi/dto/CreateUserRequest.java
@@ -0,0 +1,15 @@
+package com.devcourse.springbootboardjpahi.dto;
+
+import com.devcourse.springbootboardjpahi.message.UserExceptionMessage;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.PositiveOrZero;
+
+public record CreateUserRequest(
+ @NotBlank(message = UserExceptionMessage.BLANK_NAME)
+ String name,
+ @PositiveOrZero(message = UserExceptionMessage.NEGATIVE_AGE)
+ Integer age,
+ String hobby
+) {
+
+}
diff --git a/src/main/java/com/devcourse/springbootboardjpahi/dto/PageResponse.java b/src/main/java/com/devcourse/springbootboardjpahi/dto/PageResponse.java
new file mode 100644
index 000000000..a355a4665
--- /dev/null
+++ b/src/main/java/com/devcourse/springbootboardjpahi/dto/PageResponse.java
@@ -0,0 +1,23 @@
+package com.devcourse.springbootboardjpahi.dto;
+
+import java.util.List;
+import lombok.Builder;
+import org.springframework.data.domain.Page;
+
+@Builder
+public record PageResponse(
+ Boolean isEmpty,
+ Integer totalPages,
+ Long totalElements,
+ List content
+) {
+
+ public static PageResponse from(Page page) {
+ return PageResponse.builder()
+ .isEmpty(page.isEmpty())
+ .totalPages(page.getTotalPages())
+ .totalElements(page.getTotalElements())
+ .content(page.getContent())
+ .build();
+ }
+}
diff --git a/src/main/java/com/devcourse/springbootboardjpahi/dto/PostDetailResponse.java b/src/main/java/com/devcourse/springbootboardjpahi/dto/PostDetailResponse.java
new file mode 100644
index 000000000..af58ea6a8
--- /dev/null
+++ b/src/main/java/com/devcourse/springbootboardjpahi/dto/PostDetailResponse.java
@@ -0,0 +1,30 @@
+package com.devcourse.springbootboardjpahi.dto;
+
+import com.devcourse.springbootboardjpahi.domain.Post;
+import com.devcourse.springbootboardjpahi.domain.User;
+import java.time.LocalDateTime;
+import lombok.Builder;
+
+@Builder
+public record PostDetailResponse(
+ Long id,
+ String title,
+ String content,
+ String authorName,
+ LocalDateTime createdAt,
+ LocalDateTime updatedAt
+) {
+
+ public static PostDetailResponse from(Post post) {
+ User author = post.getUser();
+
+ return PostDetailResponse.builder()
+ .id(post.getId())
+ .title(post.getTitle())
+ .content(post.getContent())
+ .authorName(author.getName())
+ .createdAt(post.getCreatedAt())
+ .updatedAt(post.getUpdatedAt())
+ .build();
+ }
+}
diff --git a/src/main/java/com/devcourse/springbootboardjpahi/dto/PostResponse.java b/src/main/java/com/devcourse/springbootboardjpahi/dto/PostResponse.java
new file mode 100644
index 000000000..11163ac89
--- /dev/null
+++ b/src/main/java/com/devcourse/springbootboardjpahi/dto/PostResponse.java
@@ -0,0 +1,28 @@
+package com.devcourse.springbootboardjpahi.dto;
+
+import com.devcourse.springbootboardjpahi.domain.Post;
+import com.devcourse.springbootboardjpahi.domain.User;
+import java.time.LocalDateTime;
+import lombok.Builder;
+
+@Builder
+public record PostResponse(
+ Long id,
+ String title,
+ String content,
+ String authorName,
+ LocalDateTime createdAt
+) {
+
+ public static PostResponse from(Post post) {
+ User author = post.getUser();
+
+ return PostResponse.builder()
+ .id(post.getId())
+ .title(post.getTitle())
+ .content(post.getContent())
+ .authorName(author.getName())
+ .createdAt(post.getCreatedAt())
+ .build();
+ }
+}
diff --git a/src/main/java/com/devcourse/springbootboardjpahi/dto/UpdatePostRequest.java b/src/main/java/com/devcourse/springbootboardjpahi/dto/UpdatePostRequest.java
new file mode 100644
index 000000000..3d9155f7f
--- /dev/null
+++ b/src/main/java/com/devcourse/springbootboardjpahi/dto/UpdatePostRequest.java
@@ -0,0 +1,14 @@
+package com.devcourse.springbootboardjpahi.dto;
+
+import com.devcourse.springbootboardjpahi.message.PostExceptionMessage;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+public record UpdatePostRequest(
+ @NotBlank(message = PostExceptionMessage.BLANK_TITLE)
+ String title,
+ @NotNull(message = PostExceptionMessage.NULL_CONTENT)
+ String content
+) {
+
+}
diff --git a/src/main/java/com/devcourse/springbootboardjpahi/dto/UserResponse.java b/src/main/java/com/devcourse/springbootboardjpahi/dto/UserResponse.java
new file mode 100644
index 000000000..f7c21958e
--- /dev/null
+++ b/src/main/java/com/devcourse/springbootboardjpahi/dto/UserResponse.java
@@ -0,0 +1,25 @@
+package com.devcourse.springbootboardjpahi.dto;
+
+import com.devcourse.springbootboardjpahi.domain.User;
+import java.time.LocalDateTime;
+import lombok.Builder;
+
+@Builder
+public record UserResponse(
+ Long id,
+ String name,
+ Integer age,
+ String hobby,
+ LocalDateTime createdAt
+) {
+
+ public static UserResponse from(User user) {
+ return UserResponse.builder()
+ .id(user.getId())
+ .name(user.getName())
+ .age(user.getAge())
+ .hobby(user.getHobby())
+ .createdAt(user.getCreatedAt())
+ .build();
+ }
+}
diff --git a/src/main/java/com/devcourse/springbootboardjpahi/message/ControllerAdviceExceptionMessage.java b/src/main/java/com/devcourse/springbootboardjpahi/message/ControllerAdviceExceptionMessage.java
new file mode 100644
index 000000000..8330de536
--- /dev/null
+++ b/src/main/java/com/devcourse/springbootboardjpahi/message/ControllerAdviceExceptionMessage.java
@@ -0,0 +1,10 @@
+package com.devcourse.springbootboardjpahi.message;
+
+public final class ControllerAdviceExceptionMessage {
+
+ public final static String INVALID_ARGUMENT = "전달된 argument가 유효하지 않습니다.";
+
+ private ControllerAdviceExceptionMessage() {
+ // Don't let anyone instantiate this class.
+ }
+}
diff --git a/src/main/java/com/devcourse/springbootboardjpahi/message/PostExceptionMessage.java b/src/main/java/com/devcourse/springbootboardjpahi/message/PostExceptionMessage.java
new file mode 100644
index 000000000..e42a5df81
--- /dev/null
+++ b/src/main/java/com/devcourse/springbootboardjpahi/message/PostExceptionMessage.java
@@ -0,0 +1,14 @@
+package com.devcourse.springbootboardjpahi.message;
+
+public final class PostExceptionMessage {
+
+ public final static String BLANK_TITLE = "제목은 공백일 수 없습니다.";
+ public final static String NULL_CONTENT = "내용이 존재하지 않습니다.";
+ public final static String INVALID_USER_ID = "유효하지 않은 유저 아이디 입니다.";
+ public final static String NO_SUCH_USER = "존재하지 않는 유저 입니다.";
+ public final static String NO_SUCH_POST = "존재하지 않는 게시글 입니다.";
+
+ private PostExceptionMessage() {
+ // Don't let anyone instantiate this class.
+ }
+}
diff --git a/src/main/java/com/devcourse/springbootboardjpahi/message/UserExceptionMessage.java b/src/main/java/com/devcourse/springbootboardjpahi/message/UserExceptionMessage.java
new file mode 100644
index 000000000..7fbf7adb4
--- /dev/null
+++ b/src/main/java/com/devcourse/springbootboardjpahi/message/UserExceptionMessage.java
@@ -0,0 +1,11 @@
+package com.devcourse.springbootboardjpahi.message;
+
+public final class UserExceptionMessage {
+
+ public final static String BLANK_NAME = "이름은 공백일 수 없습니다.";
+ public final static String NEGATIVE_AGE = "나이는 음수일 수 없습니다.";
+
+ private UserExceptionMessage() {
+ // Don't let anyone instantiate this class.
+ }
+}
diff --git a/src/main/java/com/devcourse/springbootboardjpahi/repository/PostRepository.java b/src/main/java/com/devcourse/springbootboardjpahi/repository/PostRepository.java
new file mode 100644
index 000000000..96169cb34
--- /dev/null
+++ b/src/main/java/com/devcourse/springbootboardjpahi/repository/PostRepository.java
@@ -0,0 +1,7 @@
+package com.devcourse.springbootboardjpahi.repository;
+
+import com.devcourse.springbootboardjpahi.domain.Post;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface PostRepository extends JpaRepository {
+}
diff --git a/src/main/java/com/devcourse/springbootboardjpahi/repository/UserRepository.java b/src/main/java/com/devcourse/springbootboardjpahi/repository/UserRepository.java
new file mode 100644
index 000000000..161853d0d
--- /dev/null
+++ b/src/main/java/com/devcourse/springbootboardjpahi/repository/UserRepository.java
@@ -0,0 +1,9 @@
+package com.devcourse.springbootboardjpahi.repository;
+
+import com.devcourse.springbootboardjpahi.domain.User;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface UserRepository extends JpaRepository {
+}
diff --git a/src/main/java/com/devcourse/springbootboardjpahi/service/PostService.java b/src/main/java/com/devcourse/springbootboardjpahi/service/PostService.java
new file mode 100644
index 000000000..3881556cc
--- /dev/null
+++ b/src/main/java/com/devcourse/springbootboardjpahi/service/PostService.java
@@ -0,0 +1,67 @@
+package com.devcourse.springbootboardjpahi.service;
+
+import com.devcourse.springbootboardjpahi.domain.Post;
+import com.devcourse.springbootboardjpahi.domain.User;
+import com.devcourse.springbootboardjpahi.dto.CreatePostRequest;
+import com.devcourse.springbootboardjpahi.dto.PageResponse;
+import com.devcourse.springbootboardjpahi.dto.PostDetailResponse;
+import com.devcourse.springbootboardjpahi.dto.PostResponse;
+import com.devcourse.springbootboardjpahi.dto.UpdatePostRequest;
+import com.devcourse.springbootboardjpahi.message.PostExceptionMessage;
+import com.devcourse.springbootboardjpahi.repository.PostRepository;
+import com.devcourse.springbootboardjpahi.repository.UserRepository;
+import java.util.NoSuchElementException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+public class PostService {
+
+ private final PostRepository postRepository;
+ private final UserRepository userRepository;
+
+ public PostResponse create(CreatePostRequest request) {
+ User author = userRepository.findById(request.userId())
+ .orElseThrow(() -> new NoSuchElementException(PostExceptionMessage.NO_SUCH_USER));
+ Post post = Post.builder()
+ .title(request.title())
+ .content(request.content())
+ .user(author)
+ .build();
+
+ Post savedPost = postRepository.save(post);
+
+ return PostResponse.from(savedPost);
+ }
+
+ @Transactional(readOnly = true)
+ public PostDetailResponse findById(Long id) {
+ Post post = postRepository.findById(id)
+ .orElseThrow(() -> new NoSuchElementException(PostExceptionMessage.NO_SUCH_POST));
+
+ return PostDetailResponse.from(post);
+ }
+
+ @Transactional
+ public PostDetailResponse updateById(Long id, UpdatePostRequest request) {
+ Post post = postRepository.findById(id)
+ .orElseThrow(() -> new NoSuchElementException(PostExceptionMessage.NO_SUCH_POST));
+
+ post.updateTitle(request.title());
+ post.updateContent(request.content());
+
+ return PostDetailResponse.from(post);
+ }
+
+ @Transactional(readOnly = true)
+ public PageResponse getPage(Pageable pageable) {
+ Page page = postRepository.findAll(pageable)
+ .map(PostResponse::from);
+
+ return PageResponse.from(page);
+ }
+}
diff --git a/src/main/java/com/devcourse/springbootboardjpahi/service/UserService.java b/src/main/java/com/devcourse/springbootboardjpahi/service/UserService.java
new file mode 100644
index 000000000..9bdbe2880
--- /dev/null
+++ b/src/main/java/com/devcourse/springbootboardjpahi/service/UserService.java
@@ -0,0 +1,35 @@
+package com.devcourse.springbootboardjpahi.service;
+
+import com.devcourse.springbootboardjpahi.domain.User;
+import com.devcourse.springbootboardjpahi.dto.CreateUserRequest;
+import com.devcourse.springbootboardjpahi.dto.UserResponse;
+import com.devcourse.springbootboardjpahi.repository.UserRepository;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+public class UserService {
+
+ private final UserRepository userRepository;
+
+ public List findAll() {
+ return userRepository.findAll()
+ .stream()
+ .map(UserResponse::from)
+ .toList();
+ }
+
+ public UserResponse create(CreateUserRequest createUserRequest) {
+ User newUser = User.builder()
+ .name(createUserRequest.name())
+ .age(createUserRequest.age())
+ .hobby(createUserRequest.hobby())
+ .build();
+
+ User savedUser = userRepository.save(newUser);
+
+ return UserResponse.from(savedUser);
+ }
+}
diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml
new file mode 100644
index 000000000..b915417c6
--- /dev/null
+++ b/src/main/resources/application.yaml
@@ -0,0 +1,13 @@
+spring:
+ datasource:
+ driver-class-name: com.mysql.cj.jdbc.Driver
+ url: jdbc:mysql://localhost:33366/prod
+ username:
+ password:
+ jpa:
+ hibernate:
+ ddl-auto: validate
+ data:
+ web:
+ pageable:
+ default-page-size: 10
diff --git a/src/test/java/com/devcourse/springbootboardjpahi/SpringbootBoardJpaHiApplicationTests.java b/src/test/java/com/devcourse/springbootboardjpahi/SpringbootBoardJpaHiApplicationTests.java
new file mode 100644
index 000000000..affdbfd46
--- /dev/null
+++ b/src/test/java/com/devcourse/springbootboardjpahi/SpringbootBoardJpaHiApplicationTests.java
@@ -0,0 +1,13 @@
+package com.devcourse.springbootboardjpahi;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+
+@SpringBootTest
+class SpringbootBoardJpaHiApplicationTests {
+
+ @Test
+ void contextLoads() {
+ }
+
+}
diff --git a/src/test/java/com/devcourse/springbootboardjpahi/controller/PostControllerTest.java b/src/test/java/com/devcourse/springbootboardjpahi/controller/PostControllerTest.java
new file mode 100644
index 000000000..6c85e5e79
--- /dev/null
+++ b/src/test/java/com/devcourse/springbootboardjpahi/controller/PostControllerTest.java
@@ -0,0 +1,416 @@
+package com.devcourse.springbootboardjpahi.controller;
+
+import static com.devcourse.springbootboardjpahi.message.PostExceptionMessage.BLANK_TITLE;
+import static com.devcourse.springbootboardjpahi.message.PostExceptionMessage.INVALID_USER_ID;
+import static com.devcourse.springbootboardjpahi.message.PostExceptionMessage.NO_SUCH_POST;
+import static com.devcourse.springbootboardjpahi.message.PostExceptionMessage.NO_SUCH_USER;
+import static com.devcourse.springbootboardjpahi.message.PostExceptionMessage.NULL_CONTENT;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.devcourse.springbootboardjpahi.domain.User;
+import com.devcourse.springbootboardjpahi.dto.CreatePostRequest;
+import com.devcourse.springbootboardjpahi.dto.PageResponse;
+import com.devcourse.springbootboardjpahi.dto.PostDetailResponse;
+import com.devcourse.springbootboardjpahi.dto.PostResponse;
+import com.devcourse.springbootboardjpahi.dto.UpdatePostRequest;
+import com.devcourse.springbootboardjpahi.service.PostService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.github.javafaker.Faker;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.NoSuchElementException;
+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.springframework.beans.factory.annotation.Autowired;
+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.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.ResultActions;
+
+@WebMvcTest(PostController.class)
+class PostControllerTest {
+
+ static final Faker faker = new Faker();
+
+ @MockBean
+ PostService postService;
+ @Autowired
+ MockMvc mockMvc;
+ @Autowired
+ ObjectMapper objectMapper;
+
+ @DisplayName("[POST] 포스트를 추가한다.")
+ @Test
+ void testCreate() throws Exception {
+ // given
+ User author = generateAuthor();
+ CreatePostRequest createPostRequest = generateCreateRequest(author.getId());
+ long id = generateId();
+ PostResponse postResponse = PostResponse.builder()
+ .id(id)
+ .title(createPostRequest.title())
+ .content(createPostRequest.content())
+ .authorName(author.getName())
+ .createdAt(LocalDateTime.now())
+ .build();
+
+ given(postService.create(createPostRequest))
+ .willReturn(postResponse);
+
+ // when
+ ResultActions actions = mockMvc.perform(post("/api/v1/posts")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(createPostRequest)));
+
+ // then
+ actions.andExpect(status().isCreated())
+ .andExpect(jsonPath("$.title", is(createPostRequest.title())))
+ .andExpect(jsonPath("$.content", is(createPostRequest.content())))
+ .andExpect(jsonPath("$.authorName", is(author.getName())));
+ }
+
+ @DisplayName("[POST] 존재하지 않는 유저의 포스트를 생성할 수 없다.")
+ @Test
+ void testCreateNoSuchUser() throws Exception {
+ // given
+ long id = generateId();
+ CreatePostRequest createPostRequest = generateCreateRequest(id);
+
+ given(postService.create(createPostRequest))
+ .willThrow(new NoSuchElementException(NO_SUCH_USER));
+
+ // when
+ ResultActions actions = mockMvc.perform(post("/api/v1/posts")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(createPostRequest)));
+
+ // then
+ actions.andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.message", is(NO_SUCH_USER)));
+ }
+
+ @DisplayName("[POST] 포스트 제목은 공백일 수 없다.")
+ @ParameterizedTest
+ @NullAndEmptySource
+ void testCreateBlankTitle(String title) throws Exception {
+ // given
+ User author = generateAuthor();
+ String content = faker.esports().player();
+ CreatePostRequest createPostRequest = new CreatePostRequest(title, content, author.getId());
+
+ // when
+ ResultActions actions = mockMvc.perform(post("/api/v1/posts")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(createPostRequest)));
+
+ // then
+ actions.andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.message", is(BLANK_TITLE)));
+ }
+
+ @DisplayName("[POST] 포스트 내용은 null일 수 없다.")
+ @Test
+ void testCreateNullContent() throws Exception {
+ // given
+ User author = generateAuthor();
+ String title = faker.esports().player();
+ CreatePostRequest createPostRequest = new CreatePostRequest(title, null, author.getId());
+
+ // when
+ ResultActions actions = mockMvc.perform(post("/api/v1/posts")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(createPostRequest)));
+
+ // then
+ actions.andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.message", is(NULL_CONTENT)));
+ }
+
+ @DisplayName("[POST] 포스트 작성자 id는 음수일 수 없다.")
+ @Test
+ void testCreateNegativeId() throws Exception {
+ // given
+ long id = faker.number().numberBetween(-1000, -1);
+ CreatePostRequest createPostRequest = generateCreateRequest(id);
+
+ // when
+ ResultActions actions = mockMvc.perform(post("/api/v1/posts")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(createPostRequest)));
+
+ // then
+ actions.andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.message", is(INVALID_USER_ID)));
+ }
+
+ @DisplayName("[GET] 포스트를 상세 조회한다.")
+ @Test
+ void testFindById() throws Exception {
+ // given
+ long id = generateId();
+ String title = faker.book().title();
+ String content = faker.shakespeare().kingRichardIIIQuote();
+ String authorName = faker.name().firstName();
+ PostDetailResponse postDetailResponse = PostDetailResponse.builder()
+ .id(id)
+ .title(title)
+ .content(content)
+ .authorName(authorName)
+ .createdAt(LocalDateTime.now())
+ .updatedAt(LocalDateTime.now())
+ .build();
+
+ given(postService.findById(id))
+ .willReturn(postDetailResponse);
+
+ // when
+ ResultActions actions = mockMvc.perform(get("/api/v1/posts/{id}", id));
+
+ // then
+ actions.andExpect(status().isOk())
+ .andExpect(jsonPath("$.id", is(postDetailResponse.id()), Long.class))
+ .andExpect(jsonPath("$.title", is(postDetailResponse.title())))
+ .andExpect(jsonPath("$.content", is(postDetailResponse.content())))
+ .andExpect(jsonPath("$.authorName", is(postDetailResponse.authorName())));
+ }
+
+ @DisplayName("[GET] 존재하지 않는 포스트를 조회할 수 없다.")
+ @Test
+ void testFindByIdNoSuchPost() throws Exception {
+ // given
+ long id = generateId();
+
+ given(postService.findById(id))
+ .willThrow(new NoSuchElementException(NO_SUCH_POST));
+
+ // when
+ ResultActions actions = mockMvc.perform(get("/api/v1/posts/{id}", id));
+
+ // then
+ actions.andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.message", is(NO_SUCH_POST)));
+ }
+
+ @DisplayName("[PUT] 포스트 제목과 내용을 수정한다.")
+ @Test
+ void testUpdateById() throws Exception {
+ // given
+ UpdatePostRequest updatePostRequest = generateUpdateRequest();
+
+ long id = generateId();
+ String authorName = faker.name().firstName();
+ PostDetailResponse postDetailResponse = PostDetailResponse.builder()
+ .id(id)
+ .title(updatePostRequest.title())
+ .content(updatePostRequest.content())
+ .authorName(authorName)
+ .createdAt(LocalDateTime.now())
+ .updatedAt(LocalDateTime.now())
+ .build();
+
+ given(postService.updateById(id, updatePostRequest))
+ .willReturn(postDetailResponse);
+
+ // when
+ ResultActions actions = mockMvc.perform(put("/api/v1/posts/{id}", id)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updatePostRequest)));
+
+ // then
+ actions.andExpect(status().isOk())
+ .andExpect(jsonPath("$.title", is(updatePostRequest.title())))
+ .andExpect(jsonPath("$.content", is(updatePostRequest.content())));
+ }
+
+ @DisplayName("[PUT] 존재하지 않는 포스트를 수정할 수 없다.")
+ @Test
+ void testUpdateByIdNoSuchPost() throws Exception {
+ // given
+ long id = generateId();
+ UpdatePostRequest updatePostRequest = generateUpdateRequest();
+
+ given(postService.updateById(id, updatePostRequest))
+ .willThrow(new NoSuchElementException(NO_SUCH_POST));
+
+ // when
+ ResultActions actions = mockMvc.perform(put("/api/v1/posts/{id}", id)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updatePostRequest)));
+
+ // then
+ actions.andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.message", is(NO_SUCH_POST)));
+ }
+
+ @DisplayName("[PUT] 포스트 제목은 공백일 수 없다.")
+ @ParameterizedTest
+ @NullAndEmptySource
+ void testUpdateBlankTitle(String title) throws Exception {
+ // given
+ long id = generateId();
+ String content = faker.shakespeare().hamletQuote();
+ UpdatePostRequest updatePostRequest = new UpdatePostRequest(title, content);
+
+ // when
+ ResultActions actions = mockMvc.perform(put("/api/v1/posts/" + id)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updatePostRequest)));
+
+ // then
+ actions.andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.message", is(BLANK_TITLE)));
+ }
+
+ @DisplayName("[PUT] 포스트 내용은 null일 수 없다.")
+ @Test
+ void testUpdateNullContent() throws Exception {
+ // given
+ long id = generateId();
+ String title = faker.book().title();
+ UpdatePostRequest updatePostRequest = new UpdatePostRequest(title, null);
+
+ // when
+ ResultActions actions = mockMvc.perform(put("/api/v1/posts/" + id)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updatePostRequest)));
+
+ // then
+ actions.andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.message", is(NULL_CONTENT)));
+ }
+
+ @DisplayName("[GET] 포스트가 없을 때 204 상태 코드를 반환한다.")
+ @Test
+ void testFindNoContent() throws Exception {
+ // given
+ PageResponse page = PageResponse.builder()
+ .isEmpty(true)
+ .totalPages(1)
+ .totalElements(0L)
+ .content(Collections.emptyList())
+ .build();
+
+ given(postService.getPage(any()))
+ .willReturn(page);
+
+ // when
+ ResultActions actions = mockMvc.perform(get("/api/v1/posts"));
+
+ // then
+ actions.andExpect(status().isNoContent());
+ }
+
+ @DisplayName("[GET] 포스트를 페이징 조회한다.")
+ @Test
+ void testFind() throws Exception {
+ // given
+ long totalCount = 35;
+ int defaultPageSize = 10;
+ int totalPages = (int) Math.ceil((double) totalCount / defaultPageSize);
+ long contentSize = totalCount % defaultPageSize;
+ List postResponses = generatePostResponsesOrderByAsc(contentSize);
+ PageResponse page = PageResponse.builder()
+ .isEmpty(false)
+ .totalPages(totalPages)
+ .totalElements(totalCount)
+ .content(postResponses)
+ .build();
+
+ given(postService.getPage(any()))
+ .willReturn(page);
+
+ // when
+ ResultActions actions = mockMvc.perform(get("/api/v1/posts")
+ .param("page", "3")
+ .param("size", "10"));
+
+ // then
+ actions.andExpect(status().isOk())
+ .andExpect(jsonPath("$.isEmpty", is(false)))
+ .andExpect(jsonPath("$.totalPages", is(totalPages)))
+ .andExpect(jsonPath("$.totalElements", is(totalCount), Long.class))
+ .andExpect(jsonPath("$.content", hasSize((int) contentSize)));
+ }
+
+ private CreatePostRequest generateCreateRequest(Long userId) {
+ String title = faker.book().title();
+ String content = faker.shakespeare().hamletQuote();
+
+ return new CreatePostRequest(title, content, userId);
+ }
+
+ private UpdatePostRequest generateUpdateRequest() {
+ String title = faker.book().title();
+ String content = faker.shakespeare().hamletQuote();
+
+ return new UpdatePostRequest(title, content);
+ }
+
+ private User generateAuthor() {
+ long id = generateId();
+ String name = faker.name().firstName();
+ int age = faker.number().numberBetween(0, 120);
+ String hobby = faker.esports().game();
+
+ return User.builder()
+ .id(id)
+ .name(name)
+ .age(age)
+ .hobby(hobby)
+ .build();
+ }
+
+ private PostResponse generatePostResponse() {
+ long id = generateId();
+ String title = faker.book().title();
+ String content = faker.shakespeare().hamletQuote();
+ User author = generateAuthor();
+
+ return PostResponse.builder()
+ .id(id)
+ .title(title)
+ .content(content)
+ .authorName(author.getName())
+ .createdAt(LocalDateTime.now())
+ .build();
+ }
+
+ private List generatePostResponsesOrderByAsc(long count) {
+ List postResponses = new ArrayList<>();
+
+ for (long id = 1; id <= count; id++) {
+ String title = faker.book().title();
+ String content = faker.shakespeare().hamletQuote();
+ User author = generateAuthor();
+
+ PostResponse postResponse = PostResponse.builder()
+ .id(id)
+ .title(title)
+ .content(content)
+ .authorName(author.getName())
+ .createdAt(LocalDateTime.now())
+ .build();
+
+ postResponses.add(postResponse);
+ }
+
+ return postResponses;
+ }
+
+ private long generateId() {
+ return Math.abs(faker.number().randomDigitNotZero());
+ }
+}
diff --git a/src/test/java/com/devcourse/springbootboardjpahi/controller/UserControllerTest.java b/src/test/java/com/devcourse/springbootboardjpahi/controller/UserControllerTest.java
new file mode 100644
index 000000000..c8a620203
--- /dev/null
+++ b/src/test/java/com/devcourse/springbootboardjpahi/controller/UserControllerTest.java
@@ -0,0 +1,164 @@
+package com.devcourse.springbootboardjpahi.controller;
+
+import static com.devcourse.springbootboardjpahi.message.UserExceptionMessage.BLANK_NAME;
+import static com.devcourse.springbootboardjpahi.message.UserExceptionMessage.NEGATIVE_AGE;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.BDDMockito.given;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.devcourse.springbootboardjpahi.dto.CreateUserRequest;
+import com.devcourse.springbootboardjpahi.dto.UserResponse;
+import com.devcourse.springbootboardjpahi.service.UserService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.github.javafaker.Faker;
+import java.time.LocalDateTime;
+import java.util.Collections;
+import java.util.List;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.ResultActions;
+
+@WebMvcTest(UserController.class)
+class UserControllerTest {
+
+ static final Faker faker = new Faker();
+
+ @MockBean
+ UserService userService;
+ @Autowired
+ MockMvc mockMvc;
+ @Autowired
+ ObjectMapper objectMapper;
+
+ @DisplayName("[GET] 사용자 정보를 모두 반환한다.")
+ @Test
+ void testFindAll() throws Exception {
+ // given
+ List mockResponses = List.of(generateUserResponse(), generateUserResponse());
+
+ given(userService.findAll())
+ .willReturn(mockResponses);
+
+ // when
+ ResultActions actions = mockMvc.perform(get("/api/v1/users"));
+
+ // then
+ actions.andExpect(status().isOk())
+ .andExpect(jsonPath("$.*", hasSize(mockResponses.size())));
+ }
+
+ @DisplayName("[GET] 등록된 사용자가 없으면 204 상태 코드를 반환한다.")
+ @Test
+ void testFindAllNoContent() throws Exception {
+ // given
+ given(userService.findAll())
+ .willReturn(Collections.emptyList());
+
+ // when
+ ResultActions actions = mockMvc.perform(get("/api/v1/users"));
+
+ // then
+ actions.andExpect(status().isNoContent());
+ }
+
+ @DisplayName("[POST] 사용자를 추가한다.")
+ @Test
+ void testCreate() throws Exception {
+ // given
+ CreateUserRequest createUserRequest = generateCreateUserRequest();
+ UserResponse userResponse = UserResponse.builder()
+ .name(createUserRequest.name())
+ .age(createUserRequest.age())
+ .hobby(createUserRequest.hobby())
+ .build();
+
+ given(userService.create(createUserRequest))
+ .willReturn(userResponse);
+
+ // when
+ ResultActions actions = mockMvc.perform(post("/api/v1/users")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(createUserRequest)));
+
+ // then
+ actions.andExpect(status().isCreated())
+ .andExpect(jsonPath("$.name", is(createUserRequest.name())))
+ .andExpect(jsonPath("$.age", is(createUserRequest.age())))
+ .andExpect(jsonPath("$.hobby", is(createUserRequest.hobby())));
+ }
+
+ @DisplayName("[POST] 이름에 null과 공백이 들어가면 예외가 발생한다.")
+ @ParameterizedTest(name = "이름 입력: {0}")
+ @NullAndEmptySource
+ @ValueSource(strings = {" ", "\n", "\r\n"})
+ void testCreateInvalidName(String name) throws Exception {
+ // given
+ int age = faker.number().numberBetween(0, 120);
+ String hobby = faker.zelda().game();
+ CreateUserRequest createUserRequest = new CreateUserRequest(name, age, hobby);
+
+ // when
+ ResultActions actions = mockMvc.perform(post("/api/v1/users")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(createUserRequest)));
+
+ // then
+ actions.andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.message", is(BLANK_NAME)));
+ }
+
+ @DisplayName("[POST] 나이는 음수일 수 없다.")
+ @Test
+ void testCreateNegativeAge() throws Exception {
+ // given
+ String name = faker.name().firstName();
+ int age = faker.number().numberBetween(-100, -1);
+ String hobby = faker.zelda().game();
+ CreateUserRequest createUserRequest = new CreateUserRequest(name, age, hobby);
+
+ // when
+ ResultActions actions = mockMvc.perform(post("/api/v1/users")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(createUserRequest)));
+
+ // then
+ actions.andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.message", is(NEGATIVE_AGE)));
+ }
+
+ CreateUserRequest generateCreateUserRequest() {
+ String name = faker.name().firstName();
+ int age = faker.number().numberBetween(0, 120);
+ String hobby = faker.esports().game();
+
+ return new CreateUserRequest(name, age, hobby);
+ }
+
+ UserResponse generateUserResponse() {
+ long id = faker.number().randomNumber();
+ String name = faker.name().firstName();
+ int age = faker.number().numberBetween(0, 120);
+ String hobby = faker.esports().game();
+ LocalDateTime createdAt = LocalDateTime.now();
+
+ return UserResponse.builder()
+ .id(id)
+ .name(name)
+ .age(age)
+ .hobby(hobby)
+ .createdAt(createdAt)
+ .build();
+ }
+}
diff --git a/src/test/java/com/devcourse/springbootboardjpahi/docs/PostControllerRestdocsTest.java b/src/test/java/com/devcourse/springbootboardjpahi/docs/PostControllerRestdocsTest.java
new file mode 100644
index 000000000..8a648838a
--- /dev/null
+++ b/src/test/java/com/devcourse/springbootboardjpahi/docs/PostControllerRestdocsTest.java
@@ -0,0 +1,409 @@
+package com.devcourse.springbootboardjpahi.docs;
+
+import static com.devcourse.springbootboardjpahi.message.PostExceptionMessage.NO_SUCH_POST;
+import static com.devcourse.springbootboardjpahi.message.PostExceptionMessage.NO_SUCH_USER;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
+import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest;
+import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse;
+import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint;
+import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
+import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields;
+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.queryParameters;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
+
+import com.devcourse.springbootboardjpahi.controller.PostController;
+import com.devcourse.springbootboardjpahi.domain.User;
+import com.devcourse.springbootboardjpahi.dto.CreatePostRequest;
+import com.devcourse.springbootboardjpahi.dto.PageResponse;
+import com.devcourse.springbootboardjpahi.dto.PostDetailResponse;
+import com.devcourse.springbootboardjpahi.dto.PostResponse;
+import com.devcourse.springbootboardjpahi.dto.UpdatePostRequest;
+import com.devcourse.springbootboardjpahi.service.PostService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.github.javafaker.Faker;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.NoSuchElementException;
+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.restdocs.mockmvc.RestDocumentationRequestBuilders;
+import org.springframework.restdocs.payload.FieldDescriptor;
+import org.springframework.restdocs.payload.JsonFieldType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.ResultActions;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+
+@AutoConfigureRestDocs
+@WebMvcTest(PostController.class)
+public class PostControllerRestdocsTest {
+
+ static final Faker faker = new Faker();
+
+ @MockBean
+ PostService postService;
+ @Autowired
+ MockMvc mockMvc;
+ @Autowired
+ ObjectMapper objectMapper;
+
+ @DisplayName("[POST] 포스트를 생성 API 테스트")
+ @Test
+ void testCreatePostsAPI() throws Exception {
+ // given
+ User author = generateAuthor();
+ CreatePostRequest createPostRequest = generateCreateRequest(author.getId());
+ long id = generateId();
+ PostResponse postResponse = PostResponse.builder()
+ .id(id)
+ .title(createPostRequest.title())
+ .content(createPostRequest.content())
+ .authorName(author.getName())
+ .createdAt(LocalDateTime.now())
+ .build();
+
+ given(postService.create(createPostRequest))
+ .willReturn(postResponse);
+
+ // when
+ ResultActions actions = mockMvc.perform(post("/api/v1/posts")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(createPostRequest)));
+
+ // then
+ actions.andDo(document("post-create",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ requestFields(
+ field("title", JsonFieldType.STRING, "Title"),
+ field("content", JsonFieldType.STRING, "Content"),
+ field("userId", JsonFieldType.NUMBER, "User ID")),
+ responseFields(
+ field("id", JsonFieldType.NUMBER, "ID"),
+ field("title", JsonFieldType.STRING, "Title"),
+ field("content", JsonFieldType.STRING, "Content"),
+ field("authorName", JsonFieldType.STRING, "Author Name"),
+ field("createdAt", JsonFieldType.STRING, "Creation Datetime"))))
+ .andDo(print());
+ }
+
+ @DisplayName("[POST] 존재하지 않는 유저의 포스트 생성 API")
+ @Test
+ void testCreateNotExistAPI() throws Exception {
+ // given
+ long id = generateId();
+ CreatePostRequest createPostRequest = generateCreateRequest(id);
+
+ given(postService.create(createPostRequest))
+ .willThrow(new NoSuchElementException(NO_SUCH_USER));
+
+ // when
+ ResultActions actions = mockMvc.perform(post("/api/v1/posts")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(createPostRequest)));
+
+ // then
+ actions.andDo(document("post-create-not-exist-user",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ requestFields(
+ field("title", JsonFieldType.STRING, "Title"),
+ field("content", JsonFieldType.STRING, "Content"),
+ field("userId", JsonFieldType.NUMBER, "User ID")),
+ responseFields(
+ field("message", JsonFieldType.STRING, "Error Message"))))
+ .andDo(print());
+ }
+
+ @DisplayName("[GET] 포스트를 상세 조회 API")
+ @Test
+ void testFindByIdAPI() throws Exception {
+ // given
+ long id = generateId();
+ String title = faker.book().title();
+ String content = faker.shakespeare().kingRichardIIIQuote();
+ String authorName = faker.name().firstName();
+ PostDetailResponse postDetailResponse = PostDetailResponse.builder()
+ .id(id)
+ .title(title)
+ .content(content)
+ .authorName(authorName)
+ .createdAt(LocalDateTime.now())
+ .updatedAt(LocalDateTime.now())
+ .build();
+
+ given(postService.findById(id))
+ .willReturn(postDetailResponse);
+
+ // when
+ MockHttpServletRequestBuilder docsGetRequest = RestDocumentationRequestBuilders.get("/api/v1/posts/{id}", id);
+ ResultActions actions = mockMvc.perform(docsGetRequest);
+
+ // then
+ actions.andDo(document("post-find-single",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ pathParameters(
+ parameterWithName("id").description("ID")),
+ responseFields(
+ field("id", JsonFieldType.NUMBER, "ID"),
+ field("title", JsonFieldType.STRING, "Title"),
+ field("content", JsonFieldType.STRING, "Content"),
+ field("authorName", JsonFieldType.STRING, "Author Name"),
+ field("createdAt", JsonFieldType.STRING, "Creation Datetime"),
+ field("updatedAt", JsonFieldType.STRING, "Last Update Datetime"))))
+ .andDo(print());
+ }
+
+ @DisplayName("[GET] 존재하지 않는 포스트 상세 조회 API")
+ @Test
+ void testFindByIdNotExistAPI() throws Exception {
+ // given
+ long id = generateId();
+
+ given(postService.findById(id))
+ .willThrow(new NoSuchElementException(NO_SUCH_POST));
+
+ // when
+ MockHttpServletRequestBuilder docsGetRequest = RestDocumentationRequestBuilders.get("/api/v1/posts/{id}", id);
+ ResultActions actions = mockMvc.perform(docsGetRequest);
+
+ // then
+ actions.andDo(document("post-find-single-not-exist",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ pathParameters(
+ parameterWithName("id").description("ID")),
+ responseFields(
+ field("message", JsonFieldType.STRING, "Error Message"))))
+ .andDo(print());
+ }
+
+ @DisplayName("[PUT] 포스트 수정 API")
+ @Test
+ void testUpdateByIdAPI() throws Exception {
+ // given
+ UpdatePostRequest updatePostRequest = generateUpdateRequest();
+
+ long id = generateId();
+ String authorName = faker.name().firstName();
+ PostDetailResponse postDetailResponse = PostDetailResponse.builder()
+ .id(id)
+ .title(updatePostRequest.title())
+ .content(updatePostRequest.content())
+ .authorName(authorName)
+ .createdAt(LocalDateTime.now())
+ .updatedAt(LocalDateTime.now())
+ .build();
+
+ given(postService.updateById(id, updatePostRequest))
+ .willReturn(postDetailResponse);
+
+ // when
+ MockHttpServletRequestBuilder docsPutRequest = RestDocumentationRequestBuilders.put("/api/v1/posts/{id}", id);
+ ResultActions actions = mockMvc.perform(docsPutRequest
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updatePostRequest)));
+
+ // then
+ actions.andDo(document("post-update",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ pathParameters(
+ parameterWithName("id").description("ID")),
+ requestFields(
+ field("title", JsonFieldType.STRING, "Title"),
+ field("content", JsonFieldType.STRING, "Content")),
+ responseFields(
+ field("id", JsonFieldType.NUMBER, "ID"),
+ field("title", JsonFieldType.STRING, "Title"),
+ field("content", JsonFieldType.STRING, "Content"),
+ field("authorName", JsonFieldType.STRING, "Author Name"),
+ field("createdAt", JsonFieldType.STRING, "Creation Datetime"),
+ field("updatedAt", JsonFieldType.STRING, "Last Update Datetime"))))
+ .andDo(print());
+ }
+
+ @DisplayName("[PUT] 존재하지 않는 포스트 수정 API")
+ @Test
+ void testUpdateByIdNotExistAPI() throws Exception {
+ // given
+ UpdatePostRequest updatePostRequest = generateUpdateRequest();
+ long id = generateId();
+
+ given(postService.updateById(id, updatePostRequest))
+ .willThrow(new NoSuchElementException(NO_SUCH_POST));
+
+ // when
+ MockHttpServletRequestBuilder docsPutRequest = RestDocumentationRequestBuilders.put("/api/v1/posts/{id}", id);
+ ResultActions actions = mockMvc.perform(docsPutRequest
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updatePostRequest)));
+
+ // then
+ actions.andDo(document("post-update-not-exist",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ pathParameters(
+ parameterWithName("id").description("ID")),
+ requestFields(
+ field("title", JsonFieldType.STRING, "Title"),
+ field("content", JsonFieldType.STRING, "Content")),
+ responseFields(
+ field("message", JsonFieldType.STRING, "Error Message"))))
+ .andDo(print());
+ }
+
+ @DisplayName("[GET] 포스트 NoContent 조회 API")
+ @Test
+ void testFindNoContentAPI() throws Exception {
+ // given
+ PageResponse page = PageResponse.builder()
+ .isEmpty(true)
+ .totalPages(1)
+ .totalElements(0L)
+ .content(Collections.emptyList())
+ .build();
+
+ given(postService.getPage(any()))
+ .willReturn(page);
+
+ // when
+ ResultActions actions = mockMvc.perform(get("/api/v1/posts"));
+
+ // then
+ actions.andDo(document("post-page-no-content"))
+ .andDo(print());
+ }
+
+ @DisplayName("[GET] 포스트를 페이징 조회 API")
+ @Test
+ void testFindAPI() throws Exception {
+ // given
+ long totalCount = 35;
+ int defaultPageSize = 10;
+ int totalPages = (int) Math.ceil((double) totalCount / defaultPageSize);
+ long contentSize = totalCount % defaultPageSize;
+ List postResponses = generatePostResponsesOrderByAsc(contentSize);
+ PageResponse page = PageResponse.builder()
+ .isEmpty(false)
+ .totalPages(totalPages)
+ .totalElements(totalCount)
+ .content(postResponses)
+ .build();
+
+ given(postService.getPage(any()))
+ .willReturn(page);
+
+ // when
+ ResultActions actions = mockMvc.perform(get("/api/v1/posts")
+ .param("page", "3")
+ .param("size", "10"));
+
+ // then
+ actions.andDo(document("post-page",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ queryParameters(
+ parameterWithName("page").description("Page"),
+ parameterWithName("size").description("Contents per Page")),
+ responseFields(
+ field("isEmpty", JsonFieldType.BOOLEAN, "True if no post is found"),
+ field("totalPages", JsonFieldType.NUMBER, "Total number of pages"),
+ field("totalElements", JsonFieldType.NUMBER, "Total number of all posts"),
+ field("content[].id", JsonFieldType.NUMBER, "Post Id"),
+ field("content[].title", JsonFieldType.STRING, "Title"),
+ field("content[].content", JsonFieldType.STRING, "Content"),
+ field("content[].authorName", JsonFieldType.STRING, "Author Name"),
+ field("content[].createdAt", JsonFieldType.STRING, "Creation Datetime"))))
+ .andDo(print());
+ }
+
+ private CreatePostRequest generateCreateRequest(Long userId) {
+ String title = faker.book().title();
+ String content = faker.shakespeare().hamletQuote();
+
+ return new CreatePostRequest(title, content, userId);
+ }
+
+ private UpdatePostRequest generateUpdateRequest() {
+ String title = faker.book().title();
+ String content = faker.shakespeare().hamletQuote();
+
+ return new UpdatePostRequest(title, content);
+ }
+
+ private User generateAuthor() {
+ long id = generateId();
+ String name = faker.name().firstName();
+ int age = faker.number().numberBetween(0, 120);
+ String hobby = faker.esports().game();
+
+ return User.builder()
+ .id(id)
+ .name(name)
+ .age(age)
+ .hobby(hobby)
+ .build();
+ }
+
+ private PostResponse generatePostResponse() {
+ long id = generateId();
+ String title = faker.book().title();
+ String content = faker.shakespeare().hamletQuote();
+ User author = generateAuthor();
+
+ return PostResponse.builder()
+ .id(id)
+ .title(title)
+ .content(content)
+ .authorName(author.getName())
+ .createdAt(LocalDateTime.now())
+ .build();
+ }
+
+ private List generatePostResponsesOrderByAsc(long count) {
+ List postResponses = new ArrayList<>();
+
+ for (long id = 1; id <= count; id++) {
+ String title = faker.book().title();
+ String content = faker.shakespeare().hamletQuote();
+ User author = generateAuthor();
+
+ PostResponse postResponse = PostResponse.builder()
+ .id(id)
+ .title(title)
+ .content(content)
+ .authorName(author.getName())
+ .createdAt(LocalDateTime.now())
+ .build();
+
+ postResponses.add(postResponse);
+ }
+
+ return postResponses;
+ }
+
+ private long generateId() {
+ return Math.abs(faker.number().randomDigitNotZero());
+ }
+
+ private FieldDescriptor field(String name, Object type, String description) {
+ return fieldWithPath(name)
+ .type(type)
+ .description(description);
+ }
+}
diff --git a/src/test/java/com/devcourse/springbootboardjpahi/docs/UserControllerRestdocsTest.java b/src/test/java/com/devcourse/springbootboardjpahi/docs/UserControllerRestdocsTest.java
new file mode 100644
index 000000000..6b9dca7df
--- /dev/null
+++ b/src/test/java/com/devcourse/springbootboardjpahi/docs/UserControllerRestdocsTest.java
@@ -0,0 +1,164 @@
+package com.devcourse.springbootboardjpahi.docs;
+
+import static org.mockito.BDDMockito.given;
+import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
+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 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 com.devcourse.springbootboardjpahi.controller.UserController;
+import com.devcourse.springbootboardjpahi.dto.CreateUserRequest;
+import com.devcourse.springbootboardjpahi.dto.UserResponse;
+import com.devcourse.springbootboardjpahi.service.UserService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.github.javafaker.Faker;
+import java.time.LocalDateTime;
+import java.util.Collections;
+import java.util.List;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders;
+import org.springframework.restdocs.payload.FieldDescriptor;
+import org.springframework.restdocs.payload.JsonFieldType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.ResultActions;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+
+@AutoConfigureRestDocs
+@WebMvcTest(UserController.class)
+public class UserControllerRestdocsTest {
+
+ static final Faker faker = new Faker();
+
+ @MockBean
+ UserService userService;
+ @Autowired
+ MockMvc mockMvc;
+ @Autowired
+ ObjectMapper objectMapper;
+
+ @DisplayName("[GET] 사용자 NoContent 조회 API")
+ @Test
+ void testFindAllNoContentAPI() throws Exception {
+ // given
+ given(userService.findAll())
+ .willReturn(Collections.emptyList());
+
+ // when
+ ResultActions actions = mockMvc.perform(get("/api/v1/users"));
+
+ // then
+ actions.andDo(document("user-find-all-no-content"))
+ .andDo(print());
+ }
+
+ @DisplayName("[GET] 전체 사용자 조회 API")
+ @Test
+ void testFindAllAPI() throws Exception {
+ // given
+ List mockResponses = List.of(generateUserResponse(), generateUserResponse());
+
+ given(userService.findAll())
+ .willReturn(mockResponses);
+
+ // when
+ ResultActions actions = mockMvc.perform(get("/api/v1/users"));
+
+ // then
+ actions.andDo(document("user-find-all",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ responseFields(
+ field("[].id", JsonFieldType.NUMBER, "Id"),
+ field("[].name", JsonFieldType.STRING, "Name"),
+ field("[].age", JsonFieldType.NUMBER, "Age"),
+ nullableField("[].hobby", JsonFieldType.STRING, "Hobby"),
+ field("[].createdAt", JsonFieldType.STRING, "Creation Datetime"))))
+ .andDo(print());
+ }
+
+ @DisplayName("[POST] 사용자 추가 API")
+ @Test
+ void testCreateAPI() throws Exception {
+ // given
+ CreateUserRequest createUserRequest = generateCreateUserRequest();
+ UserResponse userResponse = UserResponse.builder()
+ .id(generateId())
+ .name(createUserRequest.name())
+ .age(createUserRequest.age())
+ .hobby(createUserRequest.hobby())
+ .createdAt(LocalDateTime.now())
+ .build();
+
+ given(userService.create(createUserRequest))
+ .willReturn(userResponse);
+
+ // when
+ MockHttpServletRequestBuilder docsPutRequest = RestDocumentationRequestBuilders.post("/api/v1/users");
+ ResultActions actions = mockMvc.perform(docsPutRequest
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(createUserRequest)));
+
+ // then
+ actions.andDo(document("user-create",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ responseFields(
+ field("id", JsonFieldType.NUMBER, "Id"),
+ field("name", JsonFieldType.STRING, "Name"),
+ field("age", JsonFieldType.NUMBER, "Age"),
+ nullableField("hobby", JsonFieldType.STRING, "Hobby"),
+ field("createdAt", JsonFieldType.STRING, "Creation Datetime"))))
+ .andDo(print());
+ }
+
+ CreateUserRequest generateCreateUserRequest() {
+ String name = faker.name().firstName();
+ int age = faker.number().numberBetween(0, 120);
+ String hobby = faker.esports().game();
+
+ return new CreateUserRequest(name, age, hobby);
+ }
+
+ UserResponse generateUserResponse() {
+ long id = generateId();
+ String name = faker.name().firstName();
+ int age = faker.number().numberBetween(0, 120);
+ String hobby = faker.esports().game();
+ LocalDateTime createdAt = LocalDateTime.now();
+
+ return UserResponse.builder()
+ .id(id)
+ .name(name)
+ .age(age)
+ .hobby(hobby)
+ .createdAt(createdAt)
+ .build();
+ }
+
+ private FieldDescriptor field(String name, Object type, String description) {
+ return fieldWithPath(name)
+ .type(type)
+ .description(description);
+ }
+
+ private FieldDescriptor nullableField(String name, Object type, String description) {
+ return fieldWithPath(name)
+ .type(type)
+ .description(description)
+ .optional();
+ }
+
+ private long generateId() {
+ return Math.abs(faker.number().randomDigitNotZero());
+ }
+}
diff --git a/src/test/java/com/devcourse/springbootboardjpahi/domain/PostTest.java b/src/test/java/com/devcourse/springbootboardjpahi/domain/PostTest.java
new file mode 100644
index 000000000..7ba1f0ab2
--- /dev/null
+++ b/src/test/java/com/devcourse/springbootboardjpahi/domain/PostTest.java
@@ -0,0 +1,201 @@
+package com.devcourse.springbootboardjpahi.domain;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatException;
+
+import com.github.javafaker.Faker;
+import java.time.LocalDateTime;
+import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
+import org.hibernate.exception.ConstraintViolationException;
+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.jdbc.AutoConfigureTestDatabase;
+import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
+import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
+
+@DataJpaTest
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+class PostTest {
+
+ static final Faker faker = new Faker();
+
+ @Autowired
+ TestEntityManager entityManager;
+
+ User author;
+
+ @BeforeEach
+ void setUp() {
+ String name = faker.name().firstName();
+ int age = faker.number().numberBetween(0, 120);
+ String hobby = faker.esports().game();
+ author = User.builder()
+ .name(name)
+ .age(age)
+ .hobby(hobby)
+ .build();
+
+ entityManager.persist(author);
+ }
+
+ @DisplayName("포스트 인스턴스를 생성한다.")
+ @Test
+ void testCreate() {
+ // given
+ String title = faker.book().title();
+ String content = faker.howIMetYourMother().catchPhrase();
+
+ // when
+ Post expected = Post.builder()
+ .title(title)
+ .content(content)
+ .user(author)
+ .build();
+
+ Post actual = entityManager.persistFlushFind(expected);
+
+ // then
+ assertThat(actual).usingRecursiveComparison()
+ .ignoringExpectedNullFields()
+ .isEqualTo(expected);
+ }
+
+ @DisplayName("제목이 null일 경우 예외가 발생한다.")
+ @Test
+ void testCreateNullName() {
+ // given
+ String content = faker.shakespeare().romeoAndJulietQuote();
+
+ // when
+ Post expected = Post.builder()
+ .content(content)
+ .user(author)
+ .build();
+
+ ThrowingCallable target = () -> entityManager.persistFlushFind(expected);
+
+ // then
+ assertThatException().isThrownBy(target)
+ .isInstanceOf(ConstraintViolationException.class)
+ .withMessageContaining("Column 'title' cannot be null");
+ }
+
+ @DisplayName("내용이 null일 경우 예외가 발생한다.")
+ @Test
+ void testCreateNullAge() {
+ // given
+ String title = faker.book().title();
+
+ // when
+ Post expected = Post.builder()
+ .title(title)
+ .user(author)
+ .build();
+
+ ThrowingCallable target = () -> entityManager.persistFlushFind(expected);
+
+ // then
+ assertThatException().isThrownBy(target)
+ .isInstanceOf(ConstraintViolationException.class)
+ .withMessageContaining("Column 'content' cannot be null");
+ }
+
+ @DisplayName("포스트의 작성자를 가져온다.")
+ @Test
+ void testMappingUser() {
+ // given // when
+ String title = faker.book().title();
+ String content = faker.gameOfThrones().quote();
+
+ Post post = Post.builder()
+ .title(title)
+ .content(content)
+ .user(author)
+ .build();
+
+ entityManager.persistAndFlush(post);
+
+ // then
+ entityManager.clear();
+
+ Post actualPost = entityManager.find(Post.class, post.getId());
+ User actualAuthor = actualPost.getUser();
+
+ assertThat(actualAuthor).isNotNull()
+ .hasFieldOrPropertyWithValue("id", author.getId())
+ .hasFieldOrPropertyWithValue("name", author.getName())
+ .hasFieldOrPropertyWithValue("age", author.getAge());
+ }
+
+ @DisplayName("포스트의 제목을 수정한다.")
+ @Test
+ void testUpdateTitle() throws InterruptedException {
+ // given
+ String title = faker.book().title();
+ String content = faker.gameOfThrones().quote();
+ Post post = Post.builder()
+ .title(title)
+ .content(content)
+ .user(author)
+ .build();
+
+ entityManager.persistAndFlush(post);
+
+ LocalDateTime beforeUpdated = post.getUpdatedAt();
+ String expected = faker.book().title();
+
+ Thread.sleep(100);
+
+ // when
+ post.updateTitle(expected);
+
+ entityManager.flush();
+
+ // then
+ entityManager.clear();
+
+ Post actualPost = entityManager.find(Post.class, post.getId());
+ String actual = actualPost.getTitle();
+ LocalDateTime afterUpdated = actualPost.getUpdatedAt();
+
+ assertThat(actual).isEqualTo(expected);
+ assertThat(afterUpdated).isAfter(beforeUpdated);
+ }
+
+ @DisplayName("포스트의 내용을 수정한다.")
+ @Test
+ void testUpdateContent() throws InterruptedException {
+ // given
+ String title = faker.book().title();
+ String content = faker.gameOfThrones().quote();
+ Post post = Post.builder()
+ .title(title)
+ .content(content)
+ .user(author)
+ .build();
+
+ entityManager.persistAndFlush(post);
+
+ LocalDateTime beforeUpdated = post.getUpdatedAt();
+ String expected = faker.gameOfThrones().quote();
+
+ Thread.sleep(100);
+
+ // when
+ post.updateContent(expected);
+
+ entityManager.flush();
+
+ // then
+ entityManager.clear();
+
+ Post actualPost = entityManager.find(Post.class, post.getId());
+ String actual = actualPost.getContent();
+ LocalDateTime afterUpdated = actualPost.getUpdatedAt();
+
+ assertThat(actual).isEqualTo(expected);
+ assertThat(afterUpdated).isAfter(beforeUpdated);
+ }
+}
diff --git a/src/test/java/com/devcourse/springbootboardjpahi/domain/UserTest.java b/src/test/java/com/devcourse/springbootboardjpahi/domain/UserTest.java
new file mode 100644
index 000000000..109734a6a
--- /dev/null
+++ b/src/test/java/com/devcourse/springbootboardjpahi/domain/UserTest.java
@@ -0,0 +1,136 @@
+package com.devcourse.springbootboardjpahi.domain;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatException;
+
+import com.github.javafaker.Faker;
+import java.util.List;
+import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
+import org.hibernate.exception.ConstraintViolationException;
+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.jdbc.AutoConfigureTestDatabase;
+import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
+import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
+
+@DataJpaTest
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+class UserTest {
+
+ static final Faker faker = new Faker();
+
+ @Autowired
+ TestEntityManager entityManager;
+
+ @DisplayName("유저 인스턴스를 생성한다.")
+ @Test
+ void testCreate() {
+ // given
+ String name = faker.name().firstName();
+ int age = faker.number().numberBetween(0, 120);
+ String hobby = faker.esports().game();
+
+ // when
+ User expected = User.builder()
+ .name(name)
+ .age(age)
+ .hobby(hobby)
+ .build();
+
+ User actual = entityManager.persistFlushFind(expected);
+
+ // then
+ assertThat(actual).usingRecursiveComparison()
+ .ignoringExpectedNullFields()
+ .isEqualTo(expected);
+ }
+
+ @DisplayName("이름이 null일 경우 예외가 발생한다.")
+ @Test
+ void testCreateNullName() {
+ // given
+ int age = faker.number().numberBetween(0, 120);
+ String hobby = faker.esports().game();
+
+ // when
+ User expected = User.builder()
+ .name(null)
+ .age(age)
+ .hobby(hobby)
+ .build();
+
+ ThrowingCallable target = () -> entityManager.persistFlushFind(expected);
+
+ // then
+ assertThatException().isThrownBy(target)
+ .isInstanceOf(ConstraintViolationException.class)
+ .withMessageContaining("Column 'name' cannot be null");
+ }
+
+ @DisplayName("나이가 null일 경우 예외가 발생한다.")
+ @Test
+ void testCreateNullAge() {
+ // given
+ String name = faker.name().firstName();
+ String hobby = faker.esports().game();
+
+ // when
+ User expected = User.builder()
+ .name(name)
+ .age(null)
+ .hobby(hobby)
+ .build();
+
+ ThrowingCallable target = () -> entityManager.persistFlushFind(expected);
+
+ // then
+ assertThatException().isThrownBy(target)
+ .isInstanceOf(ConstraintViolationException.class)
+ .withMessageContaining("Column 'age' cannot be null");
+ }
+
+ @DisplayName("유저의 포스트들을 가져온다.")
+ @Test
+ void testMappingPosts() {
+ // given
+ String name = faker.name().firstName();
+ int age = faker.number().numberBetween(0, 120);
+ String hobby = faker.esports().game();
+ User author = User.builder()
+ .name(name)
+ .age(age)
+ .hobby(hobby)
+ .build();
+
+ entityManager.persistAndFlush(author);
+
+ // when
+ String title = faker.book().title();
+ String content = faker.harryPotter().quote();
+ Post firstPost = Post.builder()
+ .title(title)
+ .content(content)
+ .user(author)
+ .build();
+ Post secondPost = Post.builder()
+ .title(title)
+ .content(content)
+ .user(author)
+ .build();
+
+ entityManager.persistAndFlush(firstPost);
+ entityManager.persistAndFlush(secondPost);
+
+ //then
+ entityManager.clear();
+
+ User actualUser = entityManager.find(User.class, author.getId());
+ List actualPostIds = actualUser.getPosts()
+ .stream()
+ .map(Post::getId)
+ .toList();
+
+ assertThat(actualPostIds).containsExactlyInAnyOrder(firstPost.getId(), secondPost.getId());
+ }
+}
diff --git a/src/test/java/com/devcourse/springbootboardjpahi/service/PostServiceTest.java b/src/test/java/com/devcourse/springbootboardjpahi/service/PostServiceTest.java
new file mode 100644
index 000000000..526155316
--- /dev/null
+++ b/src/test/java/com/devcourse/springbootboardjpahi/service/PostServiceTest.java
@@ -0,0 +1,231 @@
+package com.devcourse.springbootboardjpahi.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+import com.devcourse.springbootboardjpahi.domain.Post;
+import com.devcourse.springbootboardjpahi.domain.User;
+import com.devcourse.springbootboardjpahi.dto.CreatePostRequest;
+import com.devcourse.springbootboardjpahi.dto.PageResponse;
+import com.devcourse.springbootboardjpahi.dto.PostDetailResponse;
+import com.devcourse.springbootboardjpahi.dto.PostResponse;
+import com.devcourse.springbootboardjpahi.dto.UpdatePostRequest;
+import com.devcourse.springbootboardjpahi.message.PostExceptionMessage;
+import com.devcourse.springbootboardjpahi.repository.PostRepository;
+import com.devcourse.springbootboardjpahi.repository.UserRepository;
+import com.github.javafaker.Faker;
+import java.util.NoSuchElementException;
+import java.util.Optional;
+import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.TestInstance.Lifecycle;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+
+@SpringBootTest
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+@TestInstance(Lifecycle.PER_CLASS)
+class PostServiceTest {
+
+ static final Faker faker = new Faker();
+
+ @Autowired
+ PostService postService;
+ @Autowired
+ PostRepository postRepository;
+ @Autowired
+ UserRepository userRepository;
+
+ @BeforeAll
+ @AfterEach
+ void clear() {
+ postRepository.deleteAll();
+ userRepository.deleteAll();
+ }
+
+ @DisplayName("포스트를 생성한다.")
+ @Test
+ void testCreate() {
+ // given
+ User user = generateAuthor();
+ userRepository.save(user);
+ CreatePostRequest request = generateCreateRequest(user.getId());
+
+ // when
+ PostResponse response = postService.create(request);
+
+ // then
+ Optional actual = postRepository.findById(response.id());
+
+ assertThat(actual).isNotEmpty();
+ assertThat(actual.get()).hasFieldOrPropertyWithValue("id", response.id())
+ .hasFieldOrPropertyWithValue("title", response.title())
+ .hasFieldOrPropertyWithValue("content", response.content());
+ }
+
+ @DisplayName("존재하지 않는 유저의 아이디일 경우 포스트 생성에 실패한다.")
+ @Test
+ void testCreateNonExistentUser() {
+ // given
+ long fakeId = faker.random().nextLong();
+ CreatePostRequest request = generateCreateRequest(fakeId);
+
+ // when
+ ThrowingCallable target = () -> postService.create(request);
+
+ // then
+ assertThatExceptionOfType(NoSuchElementException.class).isThrownBy(target)
+ .withMessage(PostExceptionMessage.NO_SUCH_USER);
+ }
+
+ @DisplayName("포스트를 상세 조회한다.")
+ @Test
+ void testFindById() {
+ // given
+ User author = generateAuthor();
+ userRepository.save(author);
+ CreatePostRequest createPostRequest = generateCreateRequest(author.getId());
+ PostResponse post = postService.create(createPostRequest);
+
+ // when
+ PostDetailResponse postDetailResponse = postService.findById(post.id());
+
+ // then
+ assertThat(postDetailResponse)
+ .hasFieldOrPropertyWithValue("id", post.id())
+ .hasFieldOrPropertyWithValue("title", post.title())
+ .hasFieldOrPropertyWithValue("content", post.content())
+ .hasFieldOrPropertyWithValue("authorName", post.authorName());
+ }
+
+ @DisplayName("존재하지 않는 포스트의 조회를 실패한다.")
+ @Test
+ void testFindByIdNonExistentId() {
+ // given
+ long id = Math.abs(faker.number().randomDigitNotZero());
+
+ // when
+ ThrowingCallable target = () -> postService.findById(id);
+
+ // then
+ assertThatExceptionOfType(NoSuchElementException.class).isThrownBy(target)
+ .withMessage(PostExceptionMessage.NO_SUCH_POST);
+ }
+
+ @DisplayName("포스트의 제목과 내용을 수정한다.")
+ @Test
+ void testUpdateById() {
+ // given
+ User user = generateAuthor();
+
+ userRepository.save(user);
+
+ CreatePostRequest createPostRequest = generateCreateRequest(user.getId());
+
+ postService.create(createPostRequest);
+
+ UpdatePostRequest expected = generateUpdateRequest();
+ Post post = postRepository.findAll()
+ .stream()
+ .findFirst()
+ .orElseThrow();
+
+ // when
+ PostDetailResponse actual = postService.updateById(post.getId(), expected);
+
+ // then
+ assertThat(actual)
+ .hasFieldOrPropertyWithValue("title", expected.title())
+ .hasFieldOrPropertyWithValue("content", expected.content());
+ }
+
+ @DisplayName("포스트가 없을 때 비어있는 페이지를 반환한다.")
+ @Test
+ void testGetPageEmpty() {
+ // given // when
+ PageResponse page = postService.getPage(Pageable.unpaged());
+
+ // then
+ assertThat(page.isEmpty()).isTrue();
+ assertThat(page.totalPages()).isEqualTo(1);
+ assertThat(page.totalElements()).isEqualTo(0);
+ assertThat(page.content()).isEmpty();
+ }
+
+ @DisplayName("포스트를 페이징 조회한다.")
+ @Test
+ void testGetPage() {
+ // given
+ int totalCount = 30;
+ int pageSize = 10;
+
+ savePosts(totalCount);
+
+ // when
+ PageRequest pageRequest = PageRequest.ofSize(pageSize);
+ PageResponse page = postService.getPage(pageRequest);
+
+ // then
+ int expectedPages = (int) Math.ceil((double) totalCount / pageSize);
+
+ assertThat(page.isEmpty()).isFalse();
+ assertThat(page.totalPages()).isEqualTo(expectedPages);
+ assertThat(page.totalElements()).isEqualTo(totalCount);
+ assertThat(page.content()).hasSize(pageSize);
+ }
+
+ private CreatePostRequest generateCreateRequest(Long userId) {
+ String title = faker.book().title();
+ String content = faker.shakespeare().hamletQuote();
+
+ return new CreatePostRequest(title, content, userId);
+ }
+
+ private UpdatePostRequest generateUpdateRequest() {
+ String title = faker.book().title();
+ String content = faker.shakespeare().hamletQuote();
+
+ return new UpdatePostRequest(title, content);
+ }
+
+ private User generateAuthor() {
+ long id = Math.abs(faker.number().randomDigitNotZero());
+ String name = faker.name().firstName();
+ int age = faker.number().numberBetween(0, 120);
+ String hobby = faker.esports().game();
+
+ return User.builder()
+ .id(id)
+ .name(name)
+ .age(age)
+ .hobby(hobby)
+ .build();
+ }
+
+ private void savePosts(int count) {
+ for (int i = 0; i < count; i++) {
+ savePost();
+ }
+ }
+
+ private Post savePost() {
+ User author = generateAuthor();
+ userRepository.save(author);
+ String title = faker.book().title();
+ String content = faker.shakespeare().asYouLikeItQuote();
+ Post post = Post.builder()
+ .title(title)
+ .content(content)
+ .user(author)
+ .build();
+
+ return postRepository.save(post);
+ }
+}
diff --git a/src/test/java/com/devcourse/springbootboardjpahi/service/UserServiceTest.java b/src/test/java/com/devcourse/springbootboardjpahi/service/UserServiceTest.java
new file mode 100644
index 000000000..8a8bdb152
--- /dev/null
+++ b/src/test/java/com/devcourse/springbootboardjpahi/service/UserServiceTest.java
@@ -0,0 +1,99 @@
+package com.devcourse.springbootboardjpahi.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.devcourse.springbootboardjpahi.domain.User;
+import com.devcourse.springbootboardjpahi.dto.CreateUserRequest;
+import com.devcourse.springbootboardjpahi.dto.UserResponse;
+import com.devcourse.springbootboardjpahi.repository.UserRepository;
+import com.github.javafaker.Faker;
+import java.util.List;
+import java.util.Optional;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.TestInstance.Lifecycle;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
+import org.springframework.boot.test.context.SpringBootTest;
+
+@SpringBootTest
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+@TestInstance(Lifecycle.PER_CLASS)
+class UserServiceTest {
+
+ static final Faker faker = new Faker();
+
+ @Autowired
+ UserService userService;
+ @Autowired
+ UserRepository userRepository;
+
+ @BeforeAll
+ @AfterEach
+ void tearDown() {
+ userRepository.deleteAll();
+ }
+
+ @DisplayName("유저를 생성한다.")
+ @Test
+ void testCreate() {
+ // given
+ CreateUserRequest createUserRequest = generateCreateUserRequest();
+
+ // when
+ UserResponse created = userService.create(createUserRequest);
+
+ // then
+ Optional actual = userRepository.findById(created.id());
+
+ assertThat(actual).isNotEmpty();
+ assertThat(actual.get()).hasFieldOrPropertyWithValue("id", created.id())
+ .hasFieldOrPropertyWithValue("name", created.name())
+ .hasFieldOrPropertyWithValue("age", created.age())
+ .hasFieldOrPropertyWithValue("hobby", created.hobby())
+ .hasFieldOrPropertyWithValue("createdAt", created.createdAt());
+ }
+
+ @DisplayName("전체 유저 목록을 조회한다.")
+ @Test
+ void testFindAll() {
+ // given
+ int userCount = faker.number().numberBetween(5, 10);
+
+ saveUsers(userCount);
+
+ // when
+ List users = userService.findAll();
+
+ // then
+ assertThat(users).hasSize(userCount);
+ }
+
+ private CreateUserRequest generateCreateUserRequest() {
+ String name = faker.name().firstName();
+ int age = faker.number().numberBetween(0, 120);
+ String hobby = faker.esports().game();
+
+ return new CreateUserRequest(name, age, hobby);
+ }
+
+ private void saveUser() {
+ CreateUserRequest createUserRequest = generateCreateUserRequest();
+ User user = User.builder()
+ .name(createUserRequest.name())
+ .age(createUserRequest.age())
+ .hobby(createUserRequest.hobby())
+ .build();
+
+ userRepository.save(user);
+ }
+
+ private void saveUsers(int count) {
+ for (int i = 0; i < count; i++) {
+ saveUser();
+ }
+ }
+}
diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml
new file mode 100644
index 000000000..4fd9195d5
--- /dev/null
+++ b/src/test/resources/application.yaml
@@ -0,0 +1,11 @@
+spring:
+ datasource:
+ driver-class-name: com.mysql.cj.jdbc.Driver
+ url: jdbc:mysql://localhost:33060/test
+ username: root
+ password: 1234
+
+ jpa:
+ hibernate:
+ ddl-auto: validate
+ show-sql: true