├── .editorconfig ├── .gitignore ├── Procfile ├── README.md ├── build.gradle.kts ├── docker ├── docker-compose-all.yml └── docker-compose.yml ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle.kts └── src ├── main ├── docker │ └── Dockerfile ├── kotlin │ └── app │ │ ├── App.kt │ │ ├── config │ │ └── MongoConfig.kt │ │ ├── customer │ │ ├── Customer.kt │ │ ├── CustomerController.kt │ │ ├── CustomerRepository.kt │ │ └── CustomerService.kt │ │ ├── initializer │ │ └── DataInitializer.kt │ │ └── util │ │ └── LocalDateTimeExtension.kt └── resources │ ├── application-dev.yml │ └── application.yml └── test ├── kotlin └── app │ ├── BaseIntegrationTest.kt │ ├── JacksonConfigTest.kt │ ├── RestTest.kt │ ├── TestHelper.kt │ ├── config │ └── ITConfig.kt │ └── customer │ ├── CustomerControllerTest.kt │ ├── CustomerGenerator.kt │ ├── CustomerRepositoryTest.kt │ └── CustomerServiceTest.kt └── resources ├── app └── visitor.json ├── application-test.yml ├── junit-platform.properties └── logback-test.xml /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_size = 4 6 | indent_style = space 7 | end_of_line = lf 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.{yml,yaml}] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS 2 | *~ 3 | .DS_Store 4 | 5 | # IDEA 6 | .idea/ 7 | out/ 8 | classes/ 9 | *.iml 10 | 11 | # Eclipse 12 | .project 13 | .settings/ 14 | 15 | # Gradle 16 | .gradle/ 17 | build/ 18 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: java $JAVA_OPTS -jar build/libs/app.jar --server.port=$PORT --spring.profiles.active=heroku 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Example [Spring Boot 2.3](http://projects.spring.io/spring-boot) application with 2 | 3 | - [Kotlin 1.3](https://kotlinlang.org) 4 | - [Java 11](http://openjdk.java.net) 5 | - [JUnit 5](http://junit.org/junit5) 6 | - [TestContainers](https://www.testcontainers.org) 7 | - [Gradle 6](https://gradle.org) 8 | 9 | Gradle configuration improvements based on articles: 10 | 11 | - [Improving the Performance of Gradle Builds](https://guides.gradle.org/performance/) 12 | - [Organizing Gradle Projects](https://docs.gradle.org/current/userguide/organizing_gradle_projects.html) 13 | 14 | WebFlux CRUD with Reactive MongoDB client 15 | 16 | ### Run 17 | 18 | ``` 19 | gradle bootRun --spring.profiles.active=dev 20 | ``` 21 | 22 | ### Build fat jar 23 | 24 | ``` 25 | gradle bootJar 26 | ``` 27 | 28 | ### POST request example for httpie to create customer 29 | 30 | ``` 31 | http POST :8080/customer name="John Doe" balance="0.99" last_deposit="2018-08-14T16:55:30" 32 | ``` 33 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.gradle.api.tasks.testing.logging.TestExceptionFormat 2 | import org.gradle.api.tasks.testing.logging.TestLogEvent 3 | import org.gradle.plugins.ide.idea.model.IdeaLanguageLevel 4 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 5 | import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask 6 | 7 | plugins { 8 | application 9 | idea 10 | kotlin("jvm") version "1.3.72" 11 | 12 | id("org.springframework.boot") version "2.3.1.RELEASE" 13 | id("io.spring.dependency-management") version "1.0.9.RELEASE" 14 | 15 | // gradle dependencyUpdates -Drevision=release 16 | id("com.github.ben-manes.versions") version "0.28.0" 17 | id("com.palantir.docker") version "0.25.0" 18 | } 19 | 20 | repositories { 21 | jcenter() 22 | mavenCentral() 23 | } 24 | 25 | val javaVer = JavaVersion.VERSION_11 26 | 27 | val kotlinLoggingVer = "1.7.10" 28 | 29 | val javaxAnnotationApiVer = "1.3.2" 30 | val javaxTransactionApiVer = "1.3" 31 | 32 | val testContainersVer = "1.14.3" 33 | val jfairyVer = "0.5.9" 34 | 35 | dependencies { 36 | implementation(kotlin("stdlib-jdk8")) 37 | implementation(kotlin("reflect")) 38 | 39 | implementation("io.github.microutils:kotlin-logging:$kotlinLoggingVer") 40 | 41 | implementation("com.fasterxml.jackson.module:jackson-module-kotlin") 42 | 43 | implementation("org.springframework.boot:spring-boot-starter-webflux") 44 | implementation("org.springframework.boot:spring-boot-starter-data-mongodb-reactive") 45 | 46 | implementation("org.springframework.boot:spring-boot-starter-actuator") 47 | 48 | testImplementation("org.springframework.boot:spring-boot-starter-test") { 49 | exclude(group = "org.junit.vintage", module = "junit-vintage-engine") 50 | } 51 | testImplementation("io.projectreactor:reactor-test") 52 | testImplementation("org.testcontainers:testcontainers:$testContainersVer") 53 | testImplementation("io.codearte.jfairy:jfairy:$jfairyVer") 54 | 55 | testImplementation("org.junit.jupiter:junit-jupiter-api") 56 | } 57 | 58 | val appName = "app" 59 | val appVer by lazy { "0.0.1+${gitRev()}" } 60 | 61 | group = "example" 62 | version = appVer 63 | 64 | application { 65 | mainClassName = "app.AppKt" 66 | applicationName = appName 67 | } 68 | 69 | java { 70 | sourceCompatibility = javaVer 71 | targetCompatibility = javaVer 72 | } 73 | 74 | idea { 75 | project { 76 | languageLevel = IdeaLanguageLevel(javaVer) 77 | } 78 | module { 79 | isDownloadJavadoc = true 80 | isDownloadSources = true 81 | } 82 | } 83 | 84 | springBoot { 85 | buildInfo { 86 | properties { 87 | artifact = "$appName-$appVer.jar" 88 | version = appVer 89 | name = appName 90 | } 91 | } 92 | } 93 | 94 | tasks { 95 | withType(KotlinCompile::class).configureEach { 96 | kotlinOptions { 97 | jvmTarget = JavaVersion.VERSION_1_8.toString() 98 | freeCompilerArgs = listOf("-progressive") 99 | } 100 | } 101 | 102 | withType(JavaCompile::class).configureEach { 103 | options.isFork = true 104 | } 105 | 106 | withType(Test::class).configureEach { 107 | maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2) 108 | .takeIf { it > 0 } ?: 1 109 | 110 | useJUnitPlatform() 111 | testLogging { 112 | showExceptions = true 113 | exceptionFormat = TestExceptionFormat.FULL 114 | showStackTraces = true 115 | showCauses = true 116 | showStandardStreams = true 117 | events = setOf( 118 | TestLogEvent.PASSED, 119 | TestLogEvent.SKIPPED, 120 | TestLogEvent.FAILED, 121 | TestLogEvent.STANDARD_OUT, 122 | TestLogEvent.STANDARD_ERROR 123 | ) 124 | } 125 | 126 | reports.html.isEnabled = false 127 | 128 | useJUnitPlatform() 129 | } 130 | 131 | wrapper { 132 | gradleVersion = "6.5" 133 | distributionType = Wrapper.DistributionType.ALL 134 | } 135 | 136 | bootJar { 137 | manifest { 138 | attributes("Multi-Release" to true) 139 | } 140 | 141 | archiveBaseName.set(appName) 142 | archiveVersion.set(appVer) 143 | 144 | if (project.hasProperty("archiveName")) { 145 | archiveFileName.set(project.properties["archiveName"] as String) 146 | } 147 | } 148 | 149 | //gradle docker -PremoteDebug 150 | docker { 151 | val build = build.get() 152 | val bootJar = bootJar.get() 153 | val dockerImageName = "${project.group}/$appName" 154 | 155 | dependsOn(build) 156 | 157 | name = "$dockerImageName:latest" 158 | tag("current", "$dockerImageName:$appVer") 159 | tag("latest", "$dockerImageName:latest") 160 | files(bootJar.archiveFile) 161 | setDockerfile(file("$projectDir/src/main/docker/Dockerfile")) 162 | buildArgs(mapOf( 163 | "JAR_FILE" to bootJar.archiveFileName.get(), 164 | "JAVA_OPTS" to dockerJavaOpts(project) 165 | )) 166 | pull(true) 167 | } 168 | 169 | register("stage") { 170 | dependsOn("build", "clean") 171 | } 172 | 173 | register("cleanOut") { 174 | delete("out") 175 | } 176 | 177 | clean { 178 | dependsOn("cleanOut") 179 | } 180 | 181 | withType(DependencyUpdatesTask::class) { 182 | resolutionStrategy { 183 | componentSelection { 184 | all { 185 | val rejected = listOf( 186 | "alpha", "beta", "rc", "cr", "m", 187 | "preview", "b", "ea", "eap" 188 | ).any { q -> 189 | candidate.version.matches( 190 | Regex("(?i).*[.-]$q[.\\d-+]*") 191 | ) 192 | } 193 | if (rejected) { 194 | reject("Release candidate") 195 | } 196 | } 197 | } 198 | } 199 | 200 | checkForGradleUpdate = true 201 | outputFormatter = "json" 202 | outputDir = "build/dependencyUpdates" 203 | reportfileName = "report" 204 | } 205 | } 206 | 207 | fun gitRev() = ProcessBuilder("git", "rev-parse", "--short", "HEAD").start().let { p -> 208 | p.waitFor(100, TimeUnit.MILLISECONDS) 209 | p.inputStream.bufferedReader().readLine() ?: "none" 210 | } 211 | 212 | fun dockerJavaOpts(project: Project): String { 213 | val baseOpts = "-XX:-TieredCompilation -XX:MaxRAMPercentage=80" 214 | 215 | if (project.hasProperty("remoteDebug")) { 216 | project.logger.lifecycle("WARNING: Remote Debugging Enabled!") 217 | return "$baseOpts -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005" 218 | } 219 | 220 | return baseOpts 221 | } 222 | -------------------------------------------------------------------------------- /docker/docker-compose-all.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | mongo: 5 | image: mongo:latest 6 | ports: 7 | - 27017:27017 8 | environment: 9 | MONGO_INITDB_DATABASE: app 10 | 11 | app: 12 | image: example/app:latest 13 | ports: 14 | - 8080:8080 15 | environment: 16 | - SPRING_DATA_MONGODB_URI=mongodb://mongo:27017/app 17 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | mongo: 5 | image: mongo:latest 6 | ports: 7 | - 27017:27017 8 | environment: 9 | MONGO_INITDB_DATABASE: app 10 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx2g -Xms2g 2 | org.gradle.parallel=true 3 | kotlin.code.style=official 4 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/barlog-m/spring-boot-2-example-app/1d6f0a493900522a28007950e88de631828f54a3/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto init 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto init 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :init 68 | @rem Get command-line arguments, handling Windows variants 69 | 70 | if not "%OS%" == "Windows_NT" goto win9xME_args 71 | 72 | :win9xME_args 73 | @rem Slurp the command line arguments. 74 | set CMD_LINE_ARGS= 75 | set _SKIP=2 76 | 77 | :win9xME_args_slurp 78 | if "x%~1" == "x" goto execute 79 | 80 | set CMD_LINE_ARGS=%* 81 | 82 | :execute 83 | @rem Setup the command line 84 | 85 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 86 | 87 | 88 | @rem Execute Gradle 89 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 90 | 91 | :end 92 | @rem End local scope for the variables with windows NT shell 93 | if "%ERRORLEVEL%"=="0" goto mainEnd 94 | 95 | :fail 96 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 97 | rem the _cmd.exe /c_ return code! 98 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 99 | exit /b 1 100 | 101 | :mainEnd 102 | if "%OS%"=="Windows_NT" endlocal 103 | 104 | :omega 105 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "spring-boot-2-example-app" 2 | 3 | pluginManagement { 4 | repositories { 5 | jcenter() 6 | mavenCentral() 7 | gradlePluginPortal() 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/main/docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM azul/zulu-openjdk-alpine:12 2 | 3 | LABEL maintainer="barlog@tanelorn.li" 4 | 5 | ARG JAVA_OPTS="-XX:-TieredCompilation -XX:MaxRAMPercentage=80" 6 | ARG PORT=8080 7 | 8 | ENV JAVA_OPTS ${JAVA_OPTS} 9 | ENV SERVER_PORT ${PORT} 10 | 11 | EXPOSE ${PORT} 12 | 13 | ARG JAR_FILE 14 | COPY ${JAR_FILE} /app.jar 15 | 16 | CMD java ${JAVA_OPTS} \ 17 | -XX:+CrashOnOutOfMemoryError \ 18 | -XX:+HeapDumpOnOutOfMemoryError \ 19 | -XX:HeapDumpPath=/tmp \ 20 | -Djava.security.egd=file:/dev/./urandom \ 21 | -jar /app.jar 22 | -------------------------------------------------------------------------------- /src/main/kotlin/app/App.kt: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication 4 | import org.springframework.boot.runApplication 5 | 6 | @SpringBootApplication 7 | open class App 8 | 9 | fun main(vararg args: String) { 10 | runApplication(*args) 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/app/config/MongoConfig.kt: -------------------------------------------------------------------------------- 1 | package app.config 2 | 3 | import com.mongodb.ReadConcern 4 | import com.mongodb.WriteConcern 5 | import org.springframework.boot.autoconfigure.mongo.MongoClientSettingsBuilderCustomizer 6 | import org.springframework.context.annotation.Bean 7 | import org.springframework.context.annotation.Configuration 8 | import java.util.concurrent.TimeUnit 9 | 10 | @Configuration 11 | open class MongoConfig { 12 | @Bean 13 | open fun mongoClientCustomizer() = MongoClientSettingsBuilderCustomizer { 14 | clientSettingsBuilder -> 15 | clientSettingsBuilder.writeConcern( 16 | WriteConcern.MAJORITY 17 | .withWTimeout(5, TimeUnit.SECONDS) 18 | .withJournal(true) 19 | ) 20 | clientSettingsBuilder.readConcern(ReadConcern.MAJORITY) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/kotlin/app/customer/Customer.kt: -------------------------------------------------------------------------------- 1 | package app.customer 2 | 3 | import org.bson.types.ObjectId 4 | import org.springframework.data.annotation.Id 5 | import org.springframework.data.mongodb.core.mapping.Document 6 | import java.math.BigDecimal 7 | import java.time.LocalDateTime 8 | 9 | @Document 10 | data class Customer( 11 | @Id val id: String = ObjectId.get().toHexString(), 12 | val name: String, 13 | val balance: BigDecimal, 14 | val lastWithdraw: LocalDateTime? = null, 15 | val lastDeposit: LocalDateTime 16 | ) 17 | -------------------------------------------------------------------------------- /src/main/kotlin/app/customer/CustomerController.kt: -------------------------------------------------------------------------------- 1 | package app.customer 2 | 3 | import org.bson.types.ObjectId 4 | import org.springframework.http.MediaType 5 | import org.springframework.web.bind.annotation.DeleteMapping 6 | import org.springframework.web.bind.annotation.GetMapping 7 | import org.springframework.web.bind.annotation.PathVariable 8 | import org.springframework.web.bind.annotation.PostMapping 9 | import org.springframework.web.bind.annotation.PutMapping 10 | import org.springframework.web.bind.annotation.RequestBody 11 | import org.springframework.web.bind.annotation.RequestMapping 12 | import org.springframework.web.bind.annotation.RequestParam 13 | import org.springframework.web.bind.annotation.RestController 14 | import reactor.core.publisher.Flux 15 | import reactor.core.publisher.Mono 16 | 17 | @RestController 18 | @RequestMapping("/customer") 19 | class CustomerController( 20 | private val service: CustomerService 21 | ) { 22 | @PostMapping(consumes = [MediaType.APPLICATION_JSON_VALUE]) 23 | fun create(@RequestBody customer: Customer): Mono = 24 | service.save(customer) 25 | 26 | @GetMapping( 27 | path = ["/{id}"], 28 | produces = [MediaType.APPLICATION_JSON_VALUE] 29 | ) 30 | fun readById(@PathVariable id: ObjectId): Mono = 31 | service.findById(id.toHexString()) 32 | 33 | @GetMapping( 34 | path = ["/by"], 35 | produces = [MediaType.APPLICATION_JSON_VALUE] 36 | ) 37 | fun readByName(@RequestParam name: String): Flux = 38 | service.findByName(name) 39 | 40 | @PutMapping(consumes = [MediaType.APPLICATION_JSON_VALUE]) 41 | fun update(@RequestBody customer: Customer): Mono = 42 | service.save(customer) 43 | 44 | @DeleteMapping("/{id}") 45 | fun delete(@PathVariable id: ObjectId): Mono = 46 | service.deleteById(id.toHexString()) 47 | } 48 | -------------------------------------------------------------------------------- /src/main/kotlin/app/customer/CustomerRepository.kt: -------------------------------------------------------------------------------- 1 | package app.customer 2 | 3 | import org.springframework.data.repository.reactive.ReactiveCrudRepository 4 | import reactor.core.publisher.Flux 5 | 6 | interface CustomerRepository : ReactiveCrudRepository { 7 | fun findByName(name: String): Flux 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/app/customer/CustomerService.kt: -------------------------------------------------------------------------------- 1 | package app.customer 2 | 3 | import org.springframework.http.HttpStatus 4 | import org.springframework.stereotype.Service 5 | import org.springframework.web.server.ResponseStatusException 6 | import reactor.core.publisher.Flux 7 | import reactor.core.publisher.Mono 8 | 9 | @Service 10 | open class CustomerService( 11 | private val repository: CustomerRepository 12 | ) { 13 | open fun findById(id: String): Mono = 14 | repository.findById(id) 15 | .switchIfEmpty(Mono.error(ResponseStatusException( 16 | HttpStatus.NOT_FOUND, "customer with id:'$id' not found"))) 17 | 18 | open fun findByName(name: String): Flux = 19 | repository.findByName(name) 20 | .switchIfEmpty(Flux.error(ResponseStatusException( 21 | HttpStatus.NOT_FOUND, "customers with name: '$name' not found"))) 22 | 23 | open fun save(customer: Customer): Mono = 24 | repository.save(customer).map { it.id } 25 | 26 | open fun deleteById(id: String): Mono = repository.deleteById(id) 27 | } 28 | -------------------------------------------------------------------------------- /src/main/kotlin/app/initializer/DataInitializer.kt: -------------------------------------------------------------------------------- 1 | package app.initializer 2 | 3 | import mu.KLogging 4 | import org.springframework.boot.context.event.ApplicationReadyEvent 5 | import org.springframework.context.ApplicationListener 6 | import org.springframework.data.mongodb.core.ReactiveMongoTemplate 7 | import org.springframework.stereotype.Component 8 | 9 | @Component 10 | open class DataInitializer( 11 | private val mongoTemplate: ReactiveMongoTemplate 12 | ) : ApplicationListener { 13 | companion object : KLogging() 14 | 15 | override fun onApplicationEvent(event: ApplicationReadyEvent) { 16 | logger.info { "initialize some data" } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/kotlin/app/util/LocalDateTimeExtension.kt: -------------------------------------------------------------------------------- 1 | package app.util 2 | 3 | import java.time.LocalDateTime 4 | import java.time.LocalTime 5 | import java.time.temporal.ChronoUnit 6 | 7 | fun LocalDateTime.truncate(): LocalDateTime = this.truncatedTo(ChronoUnit.MILLIS) 8 | 9 | fun LocalTime.truncate(): LocalTime = this.truncatedTo(ChronoUnit.MILLIS) 10 | -------------------------------------------------------------------------------- /src/main/resources/application-dev.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | data: 3 | mongodb: 4 | uri: mongodb://localhost:27017 5 | 6 | logging: 7 | level: 8 | root: DEBUG 9 | org.springframework.web.reactive.result.method: DEBUG 10 | -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | main: 3 | banner-mode: off 4 | lazy-initialization: true 5 | application.name: app 6 | output.ansi.enabled: always 7 | jmx.enabled: false 8 | 9 | data: 10 | mongodb: 11 | database: app 12 | field-naming-strategy: org.springframework.data.mapping.model.SnakeCaseFieldNamingStrategy 13 | repositories: 14 | type: REACTIVE 15 | 16 | jackson: 17 | property-naming-strategy: SNAKE_CASE 18 | deserialization: 19 | adjust-dates-to-context-time-zone: false 20 | default-property-inclusion: non_null 21 | 22 | server: 23 | port: 8080 24 | 25 | management: 26 | endpoint.health.show-details: always 27 | endpoints: 28 | enabled-by-default: true 29 | web.exposure.include: "health, info, env, configprops, metrics" 30 | jmx.exposure.exclude: "*" 31 | metrics.export.jmx.enabled: false 32 | 33 | logging: 34 | pattern: 35 | level: "%clr(%-5p)" 36 | console: "%date{yyyy.MM.dd HH:mm:ss.SSS} ${LOG_LEVEL_PATTERN} %clr([%thread]){magenta} %clr(%logger{-1}){cyan}->%clr(%method){blue}: %m%n${LOG_EXCEPTION_CONVERSION_WORD:%wEx}" 37 | register-shutdown-hook: true 38 | level.root: INFO 39 | -------------------------------------------------------------------------------- /src/test/kotlin/app/BaseIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import app.config.ITConfig 4 | import mu.KLogging 5 | import org.junit.jupiter.api.parallel.Execution 6 | import org.junit.jupiter.api.parallel.ExecutionMode.SAME_THREAD 7 | import org.springframework.boot.test.context.SpringBootTest 8 | import org.springframework.boot.test.context.SpringBootTest.WebEnvironment 9 | import org.springframework.test.context.ActiveProfiles 10 | import org.springframework.boot.test.util.TestPropertyValues 11 | import org.springframework.context.ConfigurableApplicationContext 12 | import org.springframework.context.ApplicationContextInitializer 13 | import org.springframework.context.ApplicationListener 14 | import org.springframework.context.event.ContextClosedEvent 15 | import org.springframework.test.context.ContextConfiguration 16 | import org.testcontainers.containers.GenericContainer 17 | import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy 18 | 19 | @SpringBootTest( 20 | classes = [ITConfig::class], 21 | webEnvironment = WebEnvironment.NONE 22 | ) 23 | @ActiveProfiles("test") 24 | @ContextConfiguration(initializers = [BaseIntegrationTest.Initializer::class]) 25 | @Execution(SAME_THREAD) 26 | abstract class BaseIntegrationTest { 27 | companion object : KLogging() { 28 | init { 29 | System.setProperty("io.netty.noUnsafe", true.toString()) 30 | } 31 | 32 | private class KGenericContainer(imageName: String) : 33 | GenericContainer(imageName) 34 | 35 | private const val MONGO_PORT = 27017 36 | 37 | private val mongoContainer: KGenericContainer = 38 | KGenericContainer("mongo:latest") 39 | .withExposedPorts(MONGO_PORT) 40 | .waitingFor(LogMessageWaitStrategy().withRegEx(".*waiting for connections on port 27017\n")) 41 | } 42 | 43 | class Initializer : ApplicationContextInitializer { 44 | override fun initialize(context: ConfigurableApplicationContext) { 45 | mongoContainer.start() 46 | 47 | logger.info { "mongo port: ${mongoContainer.getMappedPort(MONGO_PORT)}" } 48 | 49 | TestPropertyValues.of( 50 | "spring.data.mongodb.uri=mongodb://localhost:${mongoContainer.getMappedPort(MONGO_PORT)}") 51 | .applyTo(context) 52 | 53 | context.addApplicationListener(ApplicationListener { _: ContextClosedEvent -> 54 | mongoContainer.stop() 55 | logger.info { "mongo container stop" } 56 | }) 57 | 58 | context.registerShutdownHook() 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/test/kotlin/app/JacksonConfigTest.kt: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import org.assertj.core.api.Assertions.assertThat 4 | import org.junit.jupiter.api.Test 5 | import org.junit.jupiter.api.extension.ExtendWith 6 | import org.junit.jupiter.api.parallel.Execution 7 | import org.junit.jupiter.api.parallel.ExecutionMode.SAME_THREAD 8 | import org.springframework.beans.factory.annotation.Autowired 9 | import org.springframework.boot.test.autoconfigure.json.JsonTest 10 | import org.springframework.boot.test.json.JacksonTester 11 | import org.springframework.test.context.ActiveProfiles 12 | import org.springframework.test.context.junit.jupiter.SpringExtension 13 | import java.time.LocalDate 14 | import java.time.LocalDateTime 15 | import java.time.LocalTime 16 | 17 | @ExtendWith(SpringExtension::class) 18 | @JsonTest 19 | @ActiveProfiles("test") 20 | @Execution(SAME_THREAD) 21 | class JacksonConfigTest { 22 | companion object { 23 | private val date = LocalDate.of(2018, 8, 14) 24 | private val time = LocalTime.of(16, 55, 30) 25 | private const val VISIT_TIME = "2018-08-14T16:55:30" 26 | } 27 | 28 | data class Visitor( 29 | val visitTime: LocalDateTime, 30 | val nullString: String? = null 31 | ) 32 | 33 | @Autowired 34 | private lateinit var json: JacksonTester 35 | 36 | @Test 37 | fun serialize() { 38 | val visitor = Visitor(LocalDateTime.of(date, time)) 39 | println(json.write(visitor)) 40 | assertThat(json.write(visitor)).isEqualTo("visitor.json") 41 | assertThat(json.write(visitor)).isEqualToJson("visitor.json") 42 | assertThat(json.write(visitor)).hasJsonPathStringValue("@.visit_time") 43 | assertThat(json.write(visitor)).extractingJsonPathStringValue("@.visit_time") 44 | .isEqualTo(VISIT_TIME) 45 | assertThat(json.write(visitor)).doesNotHaveJsonPathValue("@.null_string") 46 | } 47 | 48 | @Test 49 | fun deserialize() { 50 | val content = "{\"visit_time\": \"$VISIT_TIME\"}" 51 | assertThat(json.parse(content)) 52 | .isEqualTo(Visitor(LocalDateTime.of(date, time))) 53 | assertThat(json.parseObject(content).visitTime).isEqualTo(VISIT_TIME) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/test/kotlin/app/RestTest.kt: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import org.junit.jupiter.api.Tag 4 | import org.springframework.boot.autoconfigure.http.codec.CodecsAutoConfiguration 5 | import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration 6 | import org.springframework.context.annotation.Import 7 | import org.springframework.test.context.ActiveProfiles 8 | 9 | @Target(AnnotationTarget.CLASS) 10 | @Retention(AnnotationRetention.RUNTIME) 11 | @Tag("rest") 12 | @ActiveProfiles("test") 13 | @Import(value = [JacksonAutoConfiguration::class, CodecsAutoConfiguration::class]) 14 | annotation class RestTest 15 | -------------------------------------------------------------------------------- /src/test/kotlin/app/TestHelper.kt: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import org.mockito.Mockito 4 | import org.springframework.test.web.reactive.server.WebTestClient.BodySpec 5 | import org.springframework.test.web.reactive.server.WebTestClient.ListBodySpec 6 | import org.springframework.test.web.reactive.server.WebTestClient.ResponseSpec 7 | 8 | inline fun ResponseSpec.kExpectBody(): BodySpec = expectBody(T::class.java) 9 | 10 | inline fun ResponseSpec.kExpectBodyList(): ListBodySpec = expectBodyList(T::class.java) 11 | 12 | fun BodySpec.kIsEqualTo(expected: T) { 13 | isEqualTo(expected) 14 | } 15 | 16 | inline fun kAnyObject(): T = kAnyObject(T::class.java) 17 | 18 | inline fun kAnyObject(t: Class): T = Mockito.any(t) 19 | -------------------------------------------------------------------------------- /src/test/kotlin/app/config/ITConfig.kt: -------------------------------------------------------------------------------- 1 | package app.config 2 | 3 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration 4 | import org.springframework.context.annotation.ComponentScan 5 | import org.springframework.context.annotation.Configuration 6 | 7 | @Configuration 8 | @EnableAutoConfiguration 9 | @ComponentScan(basePackages = ["app"]) 10 | open class ITConfig 11 | -------------------------------------------------------------------------------- /src/test/kotlin/app/customer/CustomerControllerTest.kt: -------------------------------------------------------------------------------- 1 | package app.customer 2 | 3 | import app.RestTest 4 | import app.kExpectBody 5 | import app.kExpectBodyList 6 | import app.kIsEqualTo 7 | import org.junit.jupiter.api.Test 8 | import org.junit.jupiter.api.parallel.Execution 9 | import org.junit.jupiter.api.parallel.ExecutionMode.SAME_THREAD 10 | import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest 11 | import org.springframework.boot.test.mock.mockito.MockBean 12 | import org.springframework.test.web.reactive.server.WebTestClient 13 | import org.springframework.beans.factory.annotation.Autowired 14 | import org.mockito.BDDMockito.given 15 | import org.springframework.http.MediaType 16 | import org.springframework.web.reactive.function.BodyInserters 17 | import reactor.core.publisher.Flux 18 | import reactor.core.publisher.Mono 19 | import java.net.URLEncoder 20 | 21 | @RestTest 22 | @WebFluxTest(CustomerController::class) 23 | @Execution(SAME_THREAD) 24 | class CustomerControllerTest { 25 | 26 | @Autowired 27 | private lateinit var webClient: WebTestClient 28 | 29 | @MockBean 30 | private lateinit var customerService: CustomerService 31 | 32 | private val customer = generateCustomer() 33 | 34 | @Test 35 | fun readById() { 36 | given(customerService.findById(customer.id)) 37 | .willReturn(Mono.just(customer)) 38 | 39 | webClient 40 | .get() 41 | .uri("/customer/${customer.id}") 42 | .accept(MediaType.APPLICATION_JSON) 43 | .exchange() 44 | .expectStatus().isOk 45 | .kExpectBody() 46 | .kIsEqualTo(customer) 47 | } 48 | 49 | @Test 50 | fun readByName() { 51 | given(customerService.findByName(customer.name)) 52 | .willReturn(Flux.just(customer)) 53 | 54 | webClient 55 | .get() 56 | .uri { builder -> 57 | builder 58 | .path("/customer/by") 59 | .queryParam("name", URLEncoder.encode(customer.name, "UTF-8")) 60 | .build() 61 | } 62 | .accept(MediaType.APPLICATION_JSON) 63 | .exchange() 64 | .expectStatus().isOk 65 | .kExpectBodyList() 66 | .kIsEqualTo(listOf(customer)) 67 | } 68 | 69 | @Test 70 | fun create() { 71 | given(customerService.save(customer)) 72 | .willReturn(Mono.just(customer.id)) 73 | 74 | webClient 75 | .post() 76 | .uri("/customer") 77 | .contentType(MediaType.APPLICATION_JSON) 78 | .body(BodyInserters.fromValue(customer)) 79 | .exchange() 80 | .expectStatus().isOk 81 | .kExpectBody() 82 | .kIsEqualTo(customer.id) 83 | } 84 | 85 | @Test 86 | fun update() { 87 | given(customerService.save(customer)) 88 | .willReturn(Mono.just(customer.id)) 89 | 90 | webClient 91 | .put() 92 | .uri("/customer") 93 | .contentType(MediaType.APPLICATION_JSON) 94 | .body(BodyInserters.fromValue(customer)) 95 | .exchange() 96 | .expectStatus().isOk 97 | .kExpectBody() 98 | .kIsEqualTo(customer.id) 99 | } 100 | 101 | @Test 102 | fun delete() { 103 | given(customerService.deleteById(customer.id)) 104 | .willReturn(Mono.empty()) 105 | 106 | webClient 107 | .delete() 108 | .uri("/customer/${customer.id}") 109 | .exchange() 110 | .expectStatus().isOk 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/test/kotlin/app/customer/CustomerGenerator.kt: -------------------------------------------------------------------------------- 1 | package app.customer 2 | 3 | import app.util.truncate 4 | import io.codearte.jfairy.Fairy 5 | import java.math.BigDecimal 6 | import java.time.LocalDateTime 7 | import java.util.concurrent.ThreadLocalRandom 8 | 9 | fun generateCustomer() = 10 | Customer( 11 | name = Fairy.create().person().fullName, 12 | balance = BigDecimal(ThreadLocalRandom.current().nextInt(1, 12000)), 13 | lastDeposit = LocalDateTime.now().truncate() 14 | .plusDays(ThreadLocalRandom.current().nextLong(1, 99)) 15 | ) 16 | -------------------------------------------------------------------------------- /src/test/kotlin/app/customer/CustomerRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | package app.customer 2 | 3 | import app.BaseIntegrationTest 4 | import io.codearte.jfairy.Fairy 5 | import org.junit.jupiter.api.Assertions.assertEquals 6 | import org.junit.jupiter.api.Assertions.assertNotNull 7 | import org.junit.jupiter.api.Assertions.assertTrue 8 | import org.junit.jupiter.api.Test 9 | import org.junit.jupiter.api.assertAll 10 | import org.springframework.beans.factory.annotation.Autowired 11 | import reactor.test.StepVerifier 12 | 13 | class CustomerRepositoryTest : BaseIntegrationTest() { 14 | @Autowired 15 | private lateinit var repository: CustomerRepository 16 | 17 | @Test 18 | fun findById() { 19 | val referenceCustomer = generateCustomer() 20 | repository.save(referenceCustomer).block() 21 | 22 | StepVerifier.create( 23 | repository 24 | .findById(referenceCustomer.id)) 25 | .assertNext { customer -> 26 | assertEquals(referenceCustomer.id, customer.id) 27 | } 28 | .verifyComplete() 29 | 30 | repository.delete(referenceCustomer).block() 31 | } 32 | 33 | @Test 34 | fun findByName() { 35 | val customer = generateCustomer() 36 | repository.save(customer).block() 37 | 38 | StepVerifier.create( 39 | repository 40 | .findByName(customer.name) 41 | .collectList()) 42 | .assertNext { customers -> 43 | assertAll({ 44 | assertTrue(customers.isNotEmpty()) 45 | assertNotNull(customers.first { it.id == customer.id }) 46 | }) 47 | } 48 | .verifyComplete() 49 | 50 | repository.delete(customer).block() 51 | } 52 | 53 | @Test 54 | fun save() { 55 | val customer = generateCustomer() 56 | repository.save(customer).block() 57 | 58 | StepVerifier.create( 59 | repository 60 | .findById(customer.id)) 61 | .expectNext(customer) 62 | .verifyComplete() 63 | 64 | repository.delete(customer).block() 65 | } 66 | 67 | @Test 68 | fun update() { 69 | val customer = generateCustomer() 70 | repository.save(customer).block() 71 | 72 | val newName = Fairy.create().person().fullName 73 | StepVerifier.create( 74 | repository.save(customer.copy(name = newName)) 75 | .flatMap { 76 | repository 77 | .findById(customer.id) 78 | .thenReturn(it.name) 79 | }) 80 | .expectNext(newName) 81 | .verifyComplete() 82 | 83 | repository.deleteById(customer.id).block() 84 | } 85 | 86 | @Test 87 | fun delete() { 88 | val customer = generateCustomer() 89 | repository.save(customer).block() 90 | 91 | StepVerifier.create( 92 | repository 93 | .delete(customer) 94 | .then( 95 | repository 96 | .findByName(customer.name) 97 | .collectList() 98 | )) 99 | .assertNext { 100 | assertTrue(it.isEmpty()) 101 | } 102 | .verifyComplete() 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/test/kotlin/app/customer/CustomerServiceTest.kt: -------------------------------------------------------------------------------- 1 | package app.customer 2 | 3 | import app.kAnyObject 4 | import org.junit.jupiter.api.Test 5 | import org.mockito.BDDMockito.given 6 | import org.mockito.Mockito.mock 7 | import org.mockito.Mockito.times 8 | import org.mockito.Mockito.verify 9 | import org.springframework.web.server.ResponseStatusException 10 | import reactor.core.publisher.Flux 11 | import reactor.core.publisher.Mono 12 | import reactor.test.StepVerifier 13 | 14 | class CustomerServiceTest { 15 | companion object { 16 | private val customer = generateCustomer() 17 | } 18 | 19 | @Test 20 | fun findById() { 21 | val repository = mock(CustomerRepository::class.java) 22 | val service = CustomerService(repository) 23 | 24 | given(repository.findById(customer.id)).willReturn(Mono.just(customer)) 25 | 26 | StepVerifier 27 | .create(service.findById(customer.id)) 28 | .expectNext(customer) 29 | .verifyComplete() 30 | 31 | verify(repository, times(1)).findById(customer.id) 32 | } 33 | 34 | @Test 35 | fun `findById not found`() { 36 | val repository = mock(CustomerRepository::class.java) 37 | val service = CustomerService(repository) 38 | 39 | given(repository.findById(kAnyObject(String::class.java))) 40 | .willReturn(Mono.empty()) 41 | 42 | StepVerifier 43 | .create(service.findById(customer.id)) 44 | .expectError(ResponseStatusException::class.java) 45 | .verify() 46 | 47 | verify(repository, times(1)).findById(customer.id) 48 | } 49 | 50 | @Test 51 | fun findByName() { 52 | val repository = mock(CustomerRepository::class.java) 53 | val service = CustomerService(repository) 54 | 55 | given(repository.findByName(customer.name)).willReturn(Flux 56 | .just(customer)) 57 | 58 | StepVerifier 59 | .create(service.findByName(customer.name)) 60 | .expectNext(customer) 61 | .verifyComplete() 62 | 63 | verify(repository, times(1)).findByName(customer.name) 64 | } 65 | 66 | @Test 67 | fun `findByName not found`() { 68 | val repository = mock(CustomerRepository::class.java) 69 | val service = CustomerService(repository) 70 | 71 | given(repository.findByName(kAnyObject(String::class.java))) 72 | .willReturn(Flux.empty()) 73 | 74 | StepVerifier 75 | .create(service.findByName(customer.name)) 76 | .expectError(ResponseStatusException::class.java) 77 | .verify() 78 | 79 | verify(repository, times(1)).findByName(customer.name) 80 | } 81 | 82 | @Test 83 | fun save() { 84 | val repository = mock(CustomerRepository::class.java) 85 | val service = CustomerService(repository) 86 | 87 | given(repository.save(customer)).willReturn(Mono.just(customer)) 88 | service.save(customer).block() 89 | verify(repository, times(1)).save(customer) 90 | } 91 | 92 | @Test 93 | fun deleteById() { 94 | val repository = mock(CustomerRepository::class.java) 95 | val service = CustomerService(repository) 96 | 97 | given(repository.deleteById(customer.id)).willReturn(Mono.empty()) 98 | service.deleteById(customer.id).block() 99 | verify(repository, times(1)).deleteById(customer.id) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/test/resources/app/visitor.json: -------------------------------------------------------------------------------- 1 | { 2 | "visit_time": "2018-08-14T16:55:30" 3 | } 4 | -------------------------------------------------------------------------------- /src/test/resources/application-test.yml: -------------------------------------------------------------------------------- 1 | spring.output.ansi.enabled: always 2 | 3 | logging: 4 | level: 5 | root: INFO 6 | com.github.dockerjava: WARN 7 | app: DEBUG 8 | -------------------------------------------------------------------------------- /src/test/resources/junit-platform.properties: -------------------------------------------------------------------------------- 1 | junit.jupiter.execution.parallel.enabled = true 2 | junit.jupiter.testinstance.lifecycle.default = per_class 3 | -------------------------------------------------------------------------------- /src/test/resources/logback-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | --------------------------------------------------------------------------------