├── .gitignore ├── LICENSE ├── README.md ├── build.gradle ├── frontend ├── .gitattributes ├── .gitignore ├── package-lock.json ├── package.json ├── scss │ └── src │ │ ├── index.scss │ │ └── ~bootstrap └── static │ ├── css │ └── index.css │ ├── img │ └── .keep │ └── js │ └── .keep ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── remove-auth.sh ├── remove-demo.sh ├── settings.gradle └── src ├── main ├── kotlin │ └── app │ │ ├── Application.kt │ │ ├── auth │ │ ├── AuthService.kt │ │ ├── CurrentUser.kt │ │ ├── SecurityContextWrapper.kt │ │ ├── config │ │ │ ├── ConfirmationEmailsConfig.kt │ │ │ └── WebSecurityConfig.kt │ │ ├── dashboard │ │ │ └── DashboardController.kt │ │ ├── login │ │ │ └── LoginController.kt │ │ ├── signup │ │ │ ├── CodeUsedAlreadyException.kt │ │ │ ├── ConfirmController.kt │ │ │ ├── ConfirmationLink.kt │ │ │ ├── ConfirmationLinkService.kt │ │ │ ├── ForceLoginService.kt │ │ │ ├── InvalidCodeException.kt │ │ │ └── SignupController.kt │ │ └── user │ │ │ ├── User.kt │ │ │ ├── UserExistsAlreadyException.kt │ │ │ ├── UserNotFoundException.kt │ │ │ └── UserRepository.kt │ │ ├── config │ │ ├── ThymeleafConfig.kt │ │ └── WebMvcConfig.kt │ │ ├── email │ │ ├── DevEmailService.kt │ │ ├── EmailMessage.kt │ │ ├── EmailService.kt │ │ ├── EmailTemplate.kt │ │ └── RealEmailService.kt │ │ ├── quiz │ │ ├── CreateQuizRequest.kt │ │ ├── Quiz.kt │ │ ├── QuizController.kt │ │ ├── QuizNotFoundException.kt │ │ ├── QuizRepository.kt │ │ ├── QuizService.kt │ │ └── images │ │ │ ├── FileSystemImageRepository.kt │ │ │ ├── ImageController.kt │ │ │ ├── ImageRepository.kt │ │ │ └── ImageUploadException.kt │ │ └── util │ │ ├── TimeProvider.kt │ │ ├── UuidProvider.kt │ │ └── getBaseUrlFrom.kt └── resources │ ├── application-cloud.yml.example │ ├── application-dev.yml │ ├── application.yml │ ├── db │ └── migration │ │ ├── V1__Create_serial_sequence.sql │ │ ├── V2__Add_users_table.sql │ │ └── V3__Add_quizzes_table.sql │ ├── static │ ├── templates │ ├── auth │ │ ├── dashboard.html │ │ ├── invalid_confirmation_link.html │ │ ├── login.html │ │ ├── signup.html │ │ └── thank_you.html │ ├── emails │ │ ├── confirmation.html │ │ └── confirmation.txt │ ├── layouts │ │ ├── _common.html │ │ ├── default.html │ │ └── narrow.html │ └── quizzes │ │ ├── _form.html │ │ ├── edit.html │ │ ├── list.html │ │ └── new.html │ └── translations │ ├── messages.properties │ └── messages_en.properties └── test ├── kotlin ├── app │ ├── auth │ │ ├── AuthServiceTest.kt │ │ ├── dashboard │ │ │ └── DashboardControllerTest.kt │ │ ├── login │ │ │ ├── ForceLoginController.kt │ │ │ └── LoginControllerTest.kt │ │ ├── signup │ │ │ ├── ConfirmControllerTest.kt │ │ │ ├── ConfirmationLinkServiceTest.kt │ │ │ └── SignupControllerTest.kt │ │ └── user │ │ │ ├── UserRepositoryTest.kt │ │ │ ├── createUserFactory.kt │ │ │ └── passwordEncoder.kt │ ├── email │ │ ├── EmailTemplateTest.kt │ │ ├── RealEmailServiceTest.kt │ │ └── TestEmailService.kt │ └── quiz │ │ ├── QuizControllerTest.kt │ │ ├── QuizRepositoryTest.kt │ │ ├── QuizServiceTest.kt │ │ ├── createQuizFactory.kt │ │ └── image │ │ └── FileSystemImageRepositoryTest.kt ├── featuretests │ ├── auth │ │ ├── ConfirmationPage.kt │ │ ├── DashboardPage.kt │ │ ├── LoginFeatureTest.kt │ │ ├── LoginPage.kt │ │ ├── LogoutFeatureTest.kt │ │ ├── SignupFeatureTest.kt │ │ ├── SignupPage.kt │ │ └── ThankYouPage.kt │ └── quiz │ │ ├── CreateQuestionFeatureTest.kt │ │ ├── CreateQuizFeatureTest.kt │ │ ├── NewQuizPage.kt │ │ ├── QuizEditPage.kt │ │ ├── QuizListFeatureTest.kt │ │ └── QuizListPage.kt ├── helpers │ ├── EmailTest.kt │ ├── FeatureTest.kt │ ├── JdbcTemplate.kt │ ├── MockMvcTest.kt │ ├── MockitoHelper.kt │ ├── RepositoryTest.kt │ ├── WaitHelper.kt │ └── dateFactory.kt └── templates │ └── emails │ └── ConfirmationTemplateTest.kt └── resources ├── application-test.yml ├── templates └── test_emails │ ├── testEmail.html │ └── testEmail.txt └── test_images └── extension_functions_quiz.png /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/workspace.xml 2 | .idea/libraries/ 3 | .idea/dbnavigator.xml 4 | .gradle/ 5 | out/ 6 | build/ 7 | 8 | # ignored because contains sensitive credentials 9 | src/main/resources/application-cloud.yml 10 | /uploads/ 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Oleksii Fedorov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kotlin + Spring Boot MVC Starter 2 | 3 | ## Batteries included 4 | 5 | - Spring Boot 2 MVC 6 | - Works with JVM 8 and JVM 11 7 | - Ready for JdbcTemplate-style repositories + ready PostgreSQL setup 8 | - Database migrations with Flyway 9 | - Spring Security email/password login + signup 10 | - Thymeleaf templates for views + Layout dialect 11 | - Sending emails and creating them using Thymeleaf templates 12 | - Setup for unit testing with MockMvcTest, RepositoryTest, EmailTest, and normal JUnit4 tests for service layer 13 | - Fast UI testing with Fluentlenium and HtmlUnit with FeatureTest 14 | - Frontend module for your stylesheets with SCSS+Bootstrap4 included 15 | - Example application with a few features implemented 16 | 17 | ## Table of contents 18 | 19 | 1. [Run the Demo app locally](#run-the-demo-app-locally) 20 | 1. [Running the tests](#running-the-tests) 21 | 1. [Familiarizing yourself with demo app structure](#familiarizing-yourself-with-demo-app-structure) 22 | 1. [Controller-Service-Repository pattern](#controller-service-repository-pattern) 23 | 1. [Removing the demo app code](#removing-the-demo-app-code) 24 | 1. [Removing the login-signup code if you do not need it](#removing-the-login-signup-code-if-you-do-not-need-it) 25 | 1. [Setting up the database](#setting-up-the-database) 26 | 1. [Setting up the database and email for deployment](#setting-up-the-database-and-email-for-deployment) 27 | 28 | ## Run the Demo app locally 29 | 30 | First make sure, you have the `quizzy` and `quizzy_test` databases in your local PostgreSQL installation: 31 | 32 | ```bash 33 | # needed to run the demo app locally 34 | createdb quizzy 35 | createuser quizzy 36 | 37 | # needed to run tests 38 | createdb quizzy_test 39 | createuser quizzy_test 40 | ``` 41 | 42 | To run the app locally, you’ll need to activate the `dev` spring profile. For that provide the environment variable: 43 | 44 | ```bash 45 | export SPRING_PROFILES_ACTIVE=dev 46 | ./gradlew bootRun 47 | ``` 48 | 49 | Once the app is done booting, you can visit [localhost:8080](http://localhost:8080) to see that it works. 50 | 51 | Activating the `dev` profile gives you the following: 52 | 53 | - When editing static files and Thymeleaf templates there is no need to restart the server, 54 | this allows for quicker development. 55 | - When sending e-mails there is no need to provide real SMTP config, instead all e-mails will be just logged in the 56 | STDOUT of the running server (`./gradlew bootRun`). 57 | - When uploading pictures, local filesystem will be used, instead of any 3rd party service. 58 | 59 | To understand this better, take a look at [application-dev.yml](./src/main/resources/application-dev.yml), 60 | and search the source code for the occurrence of `@Profile("dev")` and `@Profile("dev", "test")`. 61 | 62 | Alternatively, you can run the application from the IntelliJ IDEA. For that go to `Application.kt` 63 | and run the `main` function. This will fail because some spring beans will be missing. 64 | 65 | You’ll need to set the `dev` spring profile. To do that, go to Run configurations -> Edit configurations 66 | -> Kotlin -> app.ApplicationKt, then: 67 | 68 | 1. Hit the `Save Configuration` button. 69 | 1. Check the `Single instance only` checkbox. 70 | 1. Go to `Environment Variables` dialog. 71 | 1. Add `SPRING_PROFILES_ACTIVE` variable with value `dev`. 72 | 1. Hit `OK` button. 73 | 74 | Now you should be able to run the application from IntelliJ IDEA. 75 | 76 | ## Running the tests 77 | 78 | You can run all the tests with Gradle: 79 | 80 | ```bash 81 | ./gradlew test 82 | ``` 83 | 84 | If your setup is correct, then all the tests should pass. 85 | 86 | Alternatively, you can run tests in IntelliJ IDEA by selecting the directory `src -> test -> kotlin` 87 | and choosing `Run 'Tests' in 'kotlin'` from the context menu or by pressing the hot key to run the current selection 88 | (for Mac: CMD+SHIFT+R, for Linux/Win: CTRL+SHIFT+R). 89 | 90 | If you want to run specific package or class, you can do that as well in IntelliJ. 91 | 92 | ## Familiarizing yourself with demo app structure 93 | 94 | Let’s begin from the top level: 95 | 96 | ``` 97 | PROJECT/ 98 | frontend/ this is where SCSS+Bootstrap4 stylesheets live 99 | src/ this is where your back-end application lives 100 | build.gradle this is where you define your dependencies with Gradle 101 | ``` 102 | 103 | Now, let’s dive into the structure of the production code for the back-end application: 104 | 105 | ``` 106 | src/ 107 | main/ 108 | kotlin/ 109 | app/ 110 | Application.kt this is our Application class—entrypoint to the app 111 | config/ config package contains general configuration of the web app 112 | email/ email package contains code helping you send emails 113 | util/ various helper functions and classes needed throughout the codebase 114 | auth/ auth package contains security, login, signup, and logout concerns 115 | quiz/ example demo application code [can be safely removed before you start] 116 | resources/ 117 | application.yml your main application configuration file [edit to your liking] 118 | application-dev.yml your local development configuration file 119 | application-cloud.yml.example copy this file to application-cloud.yml and fill in the blanks [for deployment] 120 | db/ 121 | migration/ this package contains Flyway migration files 122 | static/ (soft-link) this soft-link allows back-end to “see” the files generated by frontend module 123 | translations/ 124 | messages*.properties these files contain translations for different languages 125 | templates/ 126 | layouts/ this package contains Thymeleaf layouts (using layout dialect) 127 | emails/ this package contains Thymeleaf email templates, render them with EmailTemplate helper 128 | auth/ this package contains login, signup and logout related templates 129 | quizzes/ this package contains demo app’s templates 130 | ``` 131 | 132 | The unit test side mirrors this structure exactly; more interesting are the feature tests: 133 | 134 | ``` 135 | src/ 136 | test/ 137 | kotlin/ 138 | app/ 139 | auth/ 140 | email/ 141 | quiz/ 142 | featuretests/ this is where all UI/feature tests live 143 | auth/ feature tests for login, signup and logout 144 | quiz/ feature tests for demo application 145 | helpers/ 146 | FeatureTest class that provides default feature test configuration 147 | EmailTest class that provides default email test configuration 148 | MockMvcTest class that provides a standalone mock mvc controller test configuration 149 | RepositoryTest class that provides default JdbcTemplate repository test configuration 150 | templates/ 151 | emails/ this package contains unit tests for email templates using EmailTemplate helper 152 | ``` 153 | 154 | Finally, let’s take a look at the front-end structure: 155 | 156 | ``` 157 | frontend/ 158 | package.json this is where you define all your dependencies 159 | node_modules/ this is where your front-end dependencies live, get these with `npm install` 160 | scss/ 161 | src/ this package is where your SCSS code lives 162 | index.scss your “root” file for stylesheets [run 'npm start' to compile & watch] 163 | static/ this is where compiled stylesheets end up 164 | ``` 165 | 166 | ## Controller-Service-Repository pattern 167 | 168 | If you take a look at the `auth` or `quiz` packages you’ll see that there is a repeating pattern: 169 | 170 | ``` 171 | app/ 172 | quiz/ 173 | QuizController [Controller] 174 | QuizService [Service] 175 | QuizRepository [Repository] 176 | .. plus some data classes .. 177 | 178 | auth/ 179 | signup/ 180 | SignupController [Controller] 181 | ConfirmController [Controller] 182 | ConfirmationLinkService [Service] 183 | ForceLoginService [Service] 184 | .. plus some data classes .. 185 | AuthService [Service] 186 | user/ 187 | UserRepository [Repository] 188 | ``` 189 | 190 | - Controllers depend (via dependency injection) on Services, and call them. 191 | - Controller is never calling the repository. 192 | - Services depend (via dependency injection) on other services or repositories, and call them. 193 | - Repositories depend only on JdbcTemplate. 194 | (You can also have JPA repositories here if you wanted, but I’ve found that they don’t scale very well, 195 | and create more trouble for you than saving in the long run, especially if you are unit-testing them). 196 | 197 | I have found this pattern very useful on countless projects, and it is an architectural sweet spot 198 | for most of the business domains. Moreover, when these three concepts are not enough, you can always 199 | have services calling other services, thus the pattern can scale to any level of domain complexity. 200 | 201 | 209 | 210 | ## Removing the demo app code 211 | 212 | To remove the demo app code, you can run a single shell-script: 213 | 214 | ```bash 215 | ./remove-demo.sh 216 | ``` 217 | 218 | ## Removing the login-signup code if you do not need it 219 | 220 | If you don’t need the classic login/signup code, you can remove it with a single shell-script: 221 | 222 | ```bash 223 | ./remove-auth.sh 224 | ``` 225 | 226 | ## Setting up the database 227 | 228 | After you have chosen the name for your development database (let’s pretend its name is `mydbname`), 229 | you’ll need to create the database and the user to access it on your local postgres installation: 230 | 231 | ```bash 232 | createdb mydbname 233 | createuser mydbname 234 | 235 | # and you’ll need a "_test" version of the db to use in the test suite: 236 | createdb mydbname_test 237 | createuser mydbname_test 238 | ``` 239 | 240 | Now, you’ll need to set this database name and user name in the `src/main/resources/application.yml`: 241 | 242 | ```yml 243 | spring: 244 | datasource: 245 | url: jdbc:postgresql://localhost/mydbname 246 | username: mydbname 247 | password: mydbname 248 | driver-class-name: org.postgresql.Driver 249 | ``` 250 | 251 | Finally, you’ll need to set similar values for the test environment in the 252 | `src/test/resources/application-test.yml`: 253 | 254 | ```yml 255 | spring: 256 | datasource: 257 | url: jdbc:postgresql://localhost/mydbname_test 258 | username: mydbname_test 259 | password: mydbname_test 260 | driver-class-name: org.postgresql.Driver 261 | ``` 262 | 263 | ## Setting up the database and email for deployment 264 | 265 | Now, once you’ve decided how you will deploy your application, you could either provide an 266 | `application-cloud.yml` configuration file (see example in `application-cloud.yml.example`), 267 | or you could supply all the required variables through the environment variables, for example: 268 | 269 | ```bash 270 | export SPRING_DATASOURCE_URL= 271 | export SPRING_DATASOURCE_USERNAME= 272 | export SPRING_DATASOURCE_PASSWORD= 273 | 274 | export SPRING_MAIL_HOST= 275 | export SPRING_MAIL_PORT= 276 | export SPRING_MAIL_USERNAME= 277 | export SPRING_MAIL_PASSWORD= 278 | 279 | export APP_AUTH_CONFIRMATION_EMAILS_FROM="Your Name " 280 | ``` 281 | 282 | ## Thanks! 283 | 284 | Thank you for reading this and giving it a try. 285 | 286 | To make me super happy you can star this repo and tweet about it! 287 | 288 | 292 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | group 'quizzy' 2 | version '1.0-SNAPSHOT' 3 | 4 | buildscript { 5 | ext.spring_boot_version = '2.1.2.RELEASE' 6 | ext.kotlin_version = '1.2.61' 7 | ext.fluentlenium_version = '3.5.2' 8 | ext.mockito_kotlin_version = '1.6.0' 9 | ext.thymeleaf_layout_version = '2.0.5' 10 | 11 | repositories { 12 | mavenCentral() 13 | } 14 | dependencies { 15 | classpath "org.springframework.boot:spring-boot-gradle-plugin:$spring_boot_version" 16 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 17 | classpath "org.jetbrains.kotlin:kotlin-allopen:$kotlin_version" 18 | } 19 | } 20 | 21 | apply plugin: 'kotlin' 22 | apply plugin: 'kotlin-spring' 23 | apply plugin: 'org.springframework.boot' 24 | apply plugin: 'io.spring.dependency-management' 25 | 26 | repositories { 27 | mavenCentral() 28 | } 29 | 30 | ext['mockito.version'] = '2.23.4' 31 | ext['selenium.version'] = '3.9.1' 32 | ext['htmlunit.version'] = '2.28' 33 | ext['selenium-htmlunit.version'] = '2.28.5' 34 | 35 | dependencies { 36 | compile "org.jetbrains.kotlin:kotlin-stdlib-jdk8" 37 | compile "org.jetbrains.kotlin:kotlin-reflect" 38 | 39 | compile 'org.springframework.boot:spring-boot-starter-jdbc' 40 | compile 'org.springframework.boot:spring-boot-starter-web' 41 | compile 'org.springframework.boot:spring-boot-starter-security' 42 | compile 'org.springframework.boot:spring-boot-starter-mail' 43 | compile 'org.springframework.boot:spring-boot-devtools' 44 | 45 | compile 'org.springframework.boot:spring-boot-starter-thymeleaf' 46 | compile "nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:$thymeleaf_layout_version" 47 | 48 | runtime 'org.postgresql:postgresql' 49 | runtime 'org.flywaydb:flyway-core' 50 | 51 | testCompile 'org.springframework.boot:spring-boot-starter-test' 52 | testCompile 'org.seleniumhq.selenium:htmlunit-driver' 53 | testCompile 'net.sourceforge.htmlunit:htmlunit' 54 | 55 | testCompile "org.fluentlenium:fluentlenium-junit:$fluentlenium_version" 56 | testCompile "org.fluentlenium:fluentlenium-assertj:$fluentlenium_version" 57 | 58 | testCompile "com.nhaarman:mockito-kotlin:$mockito_kotlin_version" 59 | } 60 | 61 | sourceCompatibility = 1.8 62 | compileKotlin { 63 | kotlinOptions { 64 | freeCompilerArgs = ["-Xjsr305=strict"] 65 | jvmTarget = "1.8" 66 | } 67 | } 68 | compileTestKotlin { 69 | kotlinOptions { 70 | freeCompilerArgs = ["-Xjsr305=strict"] 71 | jvmTarget = "1.8" 72 | } 73 | } 74 | 75 | wrapper { 76 | gradleVersion = '5.1.1' 77 | } -------------------------------------------------------------------------------- /frontend/.gitattributes: -------------------------------------------------------------------------------- 1 | static/**/* binary 2 | scss/out/**/* binary -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "watch:postcss": "postcss --watch ./scss/out/*.css --use autoprefixer --use css-mqpacker --use cssnano -d ./static/css", 4 | "watch:sass": "node-sass --watch --output ./scss/out --source-map true --source-map-contents ./scss/src/index.scss", 5 | "start": "npm-run-all --parallel watch:**" 6 | }, 7 | "private": true, 8 | "dependencies": { 9 | "autoprefixer": "^9.1.3", 10 | "bootstrap": "^4.1.3", 11 | "css-mqpacker": "^7.0.0", 12 | "cssnano": "^4.1.0", 13 | "node-sass": "^4.9.3", 14 | "npm-run-all": "^4.1.3", 15 | "postcss-cli": "^6.0.0" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /frontend/scss/src/index.scss: -------------------------------------------------------------------------------- 1 | @import "~bootstrap/scss/bootstrap"; 2 | -------------------------------------------------------------------------------- /frontend/scss/src/~bootstrap: -------------------------------------------------------------------------------- 1 | ../../node_modules/bootstrap -------------------------------------------------------------------------------- /frontend/static/img/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waterlink/kotlin-spring-boot-mvc-starter/e4968ba369ce40258e9d3c069d918430fef015c0/frontend/static/img/.keep -------------------------------------------------------------------------------- /frontend/static/js/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waterlink/kotlin-spring-boot-mvc-starter/e4968ba369ce40258e9d3c069d918430fef015c0/frontend/static/js/.keep -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waterlink/kotlin-spring-boot-mvc-starter/e4968ba369ce40258e9d3c069d918430fef015c0/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Mon Jan 28 11:57:38 CET 2019 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-all.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /remove-auth.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | ./remove-demo.sh 6 | 7 | rm -r ./src/main/kotlin/app/auth/ || true 8 | rm -r ./src/test/kotlin/app/auth/ || true 9 | rm -r ./src/test/kotlin/featuretests/auth/ || true 10 | rm -r ./src/test/kotlin/templates/emails/* || true 11 | rm -r ./src/main/resources/db/migration/V2__Add_users_table.sql || true 12 | rm -r ./src/main/resources/templates/auth/ || true 13 | rm -r ./src/main/resources/templates/emails/* || true 14 | 15 | deleteLineContaining() { 16 | local file_name="${1}" 17 | local match="${2}" 18 | local line_number=$(grep -n "${match}" "${file_name}" | awk -F ":" '{print $1}') 19 | if [[ ! -z "${line_number}" ]]; then 20 | sed -i.bak -e "${line_number}d" "${file_name}" 21 | fi 22 | } 23 | 24 | deleteLineContaining ./src/test/kotlin/helpers/FeatureTest.kt "auth.AuthService" 25 | deleteLineContaining ./src/test/kotlin/helpers/FeatureTest.kt "auth.DashboardPage" 26 | deleteLineContaining ./src/test/kotlin/helpers/FeatureTest.kt "auth.ConfirmationPage" 27 | deleteLineContaining ./src/test/kotlin/helpers/FeatureTest.kt "auth.LoginPage" 28 | deleteLineContaining ./src/test/kotlin/helpers/FeatureTest.kt "auth.SignupPage" 29 | deleteLineContaining ./src/test/kotlin/helpers/FeatureTest.kt "auth.ThankYouPage" 30 | deleteLineContaining ./src/test/kotlin/helpers/FeatureTest.kt "delete from users" 31 | 32 | deleteLineContainingWithAnnotation() { 33 | local file_name="${1}" 34 | local match="${2}" 35 | local line_number=$(grep -n "${match}" "${file_name}" | awk -F ":" '{print $1}') 36 | local annotation_line_number=$((line_number-1)) 37 | if [[ ! -z "${line_number}" ]]; then 38 | sed -i.bak -e "${annotation_line_number}d;${line_number}d" "${file_name}" 39 | fi 40 | } 41 | 42 | deleteLineContainingWithAnnotation ./src/test/kotlin/helpers/FeatureTest.kt "loginPage: LoginPage" 43 | deleteLineContainingWithAnnotation ./src/test/kotlin/helpers/FeatureTest.kt "signupPage: SignupPage" 44 | deleteLineContainingWithAnnotation ./src/test/kotlin/helpers/FeatureTest.kt "thankYouPage: ThankYouPage" 45 | deleteLineContainingWithAnnotation ./src/test/kotlin/helpers/FeatureTest.kt "dashboardPage: DashboardPage" 46 | deleteLineContainingWithAnnotation ./src/test/kotlin/helpers/FeatureTest.kt "confirmationPage: ConfirmationPage" 47 | deleteLineContainingWithAnnotation ./src/test/kotlin/helpers/FeatureTest.kt "authService: AuthService" 48 | 49 | rm ./src/test/kotlin/helpers/FeatureTest.kt.bak || true 50 | 51 | ./gradlew clean -------------------------------------------------------------------------------- /remove-demo.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | rm -r ./src/main/kotlin/app/quiz/ || true 6 | rm -r ./src/test/kotlin/app/quiz/ || true 7 | rm -r ./src/test/kotlin/featuretests/quiz/ || true 8 | rm -r ./src/main/resources/db/migration/V3__Add_quizzes_table.sql || true 9 | rm -r ./src/main/resources/templates/quizzes/ || true 10 | 11 | deleteLineContaining() { 12 | local file_name="${1}" 13 | local match="${2}" 14 | local line_number=$(grep -n "${match}" "${file_name}" | awk -F ":" '{print $1}') 15 | if [[ ! -z "${line_number}" ]]; then 16 | sed -i.bak -e "${line_number}d" "${file_name}" 17 | fi 18 | } 19 | 20 | deleteLineContaining ./src/test/kotlin/helpers/FeatureTest.kt "quiz.QuizService" 21 | deleteLineContaining ./src/test/kotlin/helpers/FeatureTest.kt "quiz.NewQuizPage" 22 | deleteLineContaining ./src/test/kotlin/helpers/FeatureTest.kt "quiz.QuizEditPage" 23 | deleteLineContaining ./src/test/kotlin/helpers/FeatureTest.kt "quiz.QuizListPage" 24 | deleteLineContaining ./src/test/kotlin/helpers/FeatureTest.kt "delete from quizzes" 25 | deleteLineContaining ./src/test/kotlin/app/auth/user/UserRepositoryTest.kt "delete from quizzes" 26 | 27 | deleteLineContainingWithAnnotation() { 28 | local file_name="${1}" 29 | local match="${2}" 30 | local line_number=$(grep -n "${match}" "${file_name}" | awk -F ":" '{print $1}') 31 | local annotation_line_number=$((line_number-1)) 32 | if [[ ! -z "${line_number}" ]]; then 33 | sed -i.bak -e "${annotation_line_number}d;${line_number}d" "${file_name}" 34 | fi 35 | } 36 | 37 | deleteLineContainingWithAnnotation ./src/test/kotlin/helpers/FeatureTest.kt "quizListPage: QuizListPage" 38 | deleteLineContainingWithAnnotation ./src/test/kotlin/helpers/FeatureTest.kt "newQuizPage: NewQuizPage" 39 | deleteLineContainingWithAnnotation ./src/test/kotlin/helpers/FeatureTest.kt "quizEditPage: QuizEditPage" 40 | deleteLineContainingWithAnnotation ./src/test/kotlin/helpers/FeatureTest.kt "quizService: QuizService" 41 | 42 | rm ./src/test/kotlin/helpers/FeatureTest.kt.bak || true 43 | rm ./src/test/kotlin/app/auth/user/UserRepositoryTest.kt.bak || true 44 | 45 | ./gradlew clean -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'backend' 2 | 3 | -------------------------------------------------------------------------------- /src/main/kotlin/app/Application.kt: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication 4 | import org.springframework.boot.runApplication 5 | 6 | @SpringBootApplication 7 | class Application 8 | 9 | fun main(args: Array) { 10 | runApplication(*args) 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/app/auth/AuthService.kt: -------------------------------------------------------------------------------- 1 | package app.auth 2 | 3 | import app.auth.config.ConfirmationEmailsConfig 4 | import app.auth.signup.CodeUsedAlreadyException 5 | import app.auth.signup.ConfirmationLink 6 | import app.auth.signup.ConfirmationLinkService 7 | import app.auth.signup.InvalidCodeException 8 | import app.email.EmailTemplate 9 | import app.auth.user.User 10 | import app.auth.user.UserExistsAlreadyException 11 | import app.auth.user.UserNotFoundException 12 | import app.auth.user.UserRepository 13 | import org.springframework.stereotype.Service 14 | import org.springframework.transaction.annotation.Transactional 15 | 16 | @Service 17 | class AuthService(private val userRepository: UserRepository, 18 | private val emailTemplate: EmailTemplate, 19 | private val confirmationLinkService: ConfirmationLinkService, 20 | private val confirmationEmailsConfig: ConfirmationEmailsConfig) { 21 | 22 | fun signupUser(user: User, baseUrl: String) { 23 | if (userRepository.findByEmail(user.email) != null) { 24 | throw UserExistsAlreadyException() 25 | } 26 | 27 | val confirmationLink = confirmationLinkService.generate(baseUrl) 28 | 29 | userRepository.create(user.copy( 30 | confirmationCode = confirmationLink.code 31 | )) 32 | 33 | sendConfirmationEmail(user, confirmationLink) 34 | } 35 | 36 | fun getCurrentUser(email: String): CurrentUser { 37 | val user = userRepository.findByEmail(email) 38 | ?: throw IllegalStateException("Current logged in user can't be missing!") 39 | 40 | val id = user.id 41 | ?: throw IllegalStateException("Existing user has to have an id!") 42 | 43 | return CurrentUser(id = id, name = user.name) 44 | } 45 | 46 | @Transactional 47 | fun confirmAndGetUser(code: String): User { 48 | val user = userRepository.findByConfirmationCode(code) 49 | ?: throw InvalidCodeException() 50 | 51 | if (user.confirmed) { 52 | throw CodeUsedAlreadyException() 53 | } 54 | 55 | val confirmedUser = user.copy(confirmed = true) 56 | 57 | userRepository.confirm(confirmedUser) 58 | return confirmedUser 59 | } 60 | 61 | @Transactional 62 | fun resendConfirmation(email: String, baseUrl: String) { 63 | val user = userRepository.findByEmail(email) 64 | ?: throw UserNotFoundException() 65 | 66 | val confirmationLink = confirmationLinkService.generate(baseUrl) 67 | 68 | userRepository.updateConfirmationCode( 69 | user.copy(confirmationCode = confirmationLink.code) 70 | ) 71 | 72 | sendConfirmationEmail(user, confirmationLink) 73 | } 74 | 75 | fun sendConfirmationEmail(user: User, confirmationLink: ConfirmationLink) { 76 | emailTemplate.send( 77 | from = confirmationEmailsConfig.from, 78 | to = "${user.name} <${user.email}>", 79 | subject = "Please confirm your account", 80 | html = "emails/confirmation.html", 81 | text = "emails/confirmation.txt", 82 | context = mapOf( 83 | "name" to user.name, 84 | "confirmUrl" to confirmationLink.href 85 | ) 86 | ) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/main/kotlin/app/auth/CurrentUser.kt: -------------------------------------------------------------------------------- 1 | package app.auth 2 | 3 | data class CurrentUser( 4 | val id: Long, 5 | val name: String 6 | ) 7 | -------------------------------------------------------------------------------- /src/main/kotlin/app/auth/SecurityContextWrapper.kt: -------------------------------------------------------------------------------- 1 | package app.auth 2 | 3 | import org.springframework.security.core.Authentication 4 | import org.springframework.security.core.context.SecurityContextHolder 5 | import org.springframework.stereotype.Service 6 | 7 | @Service 8 | class SecurityContextWrapper { 9 | var authentication: Authentication 10 | get() = SecurityContextHolder.getContext().authentication 11 | set(authentication) { 12 | SecurityContextHolder.getContext().authentication = authentication 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/kotlin/app/auth/config/ConfirmationEmailsConfig.kt: -------------------------------------------------------------------------------- 1 | package app.auth.config 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties 4 | import org.springframework.stereotype.Component 5 | 6 | @Component 7 | @ConfigurationProperties("app.auth.confirmation-emails") 8 | class ConfirmationEmailsConfig { 9 | lateinit var from: String 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/app/auth/config/WebSecurityConfig.kt: -------------------------------------------------------------------------------- 1 | package app.auth.config 2 | 3 | import app.auth.user.UserRepository 4 | import org.springframework.beans.factory.annotation.Autowired 5 | import org.springframework.beans.factory.annotation.Value 6 | import org.springframework.context.annotation.Bean 7 | import org.springframework.context.annotation.Configuration 8 | import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder 9 | import org.springframework.security.config.annotation.web.builders.HttpSecurity 10 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity 11 | import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter 12 | import org.springframework.security.core.userdetails.UserDetailsService 13 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder 14 | import org.springframework.security.crypto.password.PasswordEncoder 15 | import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler 16 | import javax.sql.DataSource 17 | 18 | @Configuration 19 | @EnableWebSecurity 20 | class WebSecurityConfig : WebSecurityConfigurerAdapter() { 21 | 22 | override fun configure(http: HttpSecurity) { 23 | http 24 | .authorizeRequests() 25 | .mvcMatchers( 26 | // Allow static 27 | "/css/**", 28 | "/js/**", 29 | "/img/**", 30 | 31 | // Allow signup-related pages 32 | "/signup", 33 | "/thank-you", 34 | "/confirm/*", 35 | "/resend-confirmation", 36 | "/login/**" 37 | ).permitAll() 38 | 39 | // allow login error page 40 | .regexMatchers("\\A/login\\?error[^&]+\\Z").permitAll() 41 | 42 | .anyRequest().authenticated() 43 | 44 | .and().formLogin() 45 | .loginPage("/login") 46 | .defaultSuccessUrl("/dashboard") 47 | .failureHandler( 48 | SimpleUrlAuthenticationFailureHandler().apply { 49 | setUseForward(true) 50 | setDefaultFailureUrl("/login?error") 51 | } 52 | ) 53 | .permitAll() 54 | 55 | .and().logout() 56 | .permitAll() 57 | } 58 | 59 | @Autowired 60 | private lateinit var dataSource: DataSource 61 | 62 | override fun configure(auth: AuthenticationManagerBuilder) { 63 | auth.jdbcAuthentication() 64 | .dataSource(dataSource) 65 | .usersByUsernameQuery(UserRepository.usersByUsernameQuery) 66 | .authoritiesByUsernameQuery(UserRepository.authoritiesByUsernameQuery) 67 | } 68 | 69 | @Bean 70 | fun passwordEncoder(@Value("\${passwordEncoder.strength:13}") strength: Int): PasswordEncoder = 71 | BCryptPasswordEncoder(strength) 72 | 73 | @Bean 74 | override fun userDetailsServiceBean(): UserDetailsService { 75 | return super.userDetailsServiceBean() 76 | } 77 | 78 | } -------------------------------------------------------------------------------- /src/main/kotlin/app/auth/dashboard/DashboardController.kt: -------------------------------------------------------------------------------- 1 | package app.auth.dashboard 2 | 3 | import app.auth.AuthService 4 | import org.springframework.stereotype.Controller 5 | import org.springframework.ui.Model 6 | import org.springframework.web.bind.annotation.GetMapping 7 | import java.security.Principal 8 | 9 | @Controller 10 | class DashboardController(private val authService: AuthService) { 11 | 12 | @GetMapping("/dashboard") 13 | fun dashboard(model: Model, principal: Principal): String { 14 | val currentUser = authService.getCurrentUser(principal.name) 15 | 16 | model.addAttribute("currentUser", currentUser) 17 | 18 | return "auth/dashboard.html" 19 | } 20 | 21 | } -------------------------------------------------------------------------------- /src/main/kotlin/app/auth/login/LoginController.kt: -------------------------------------------------------------------------------- 1 | package app.auth.login 2 | 3 | import org.springframework.security.authentication.DisabledException 4 | import org.springframework.security.web.WebAttributes 5 | import org.springframework.stereotype.Controller 6 | import org.springframework.ui.Model 7 | import org.springframework.web.bind.annotation.RequestMapping 8 | import org.springframework.web.bind.annotation.RequestParam 9 | import javax.servlet.http.HttpServletRequest 10 | 11 | @Controller 12 | class LoginController { 13 | 14 | @RequestMapping("/login", params = ["error"]) 15 | fun loginFailure(@RequestParam error: String, 16 | request: HttpServletRequest, 17 | model: Model): String { 18 | 19 | var concreteError = "login.bad_credentials" 20 | 21 | val exception = request.getAttribute(WebAttributes.AUTHENTICATION_EXCEPTION) 22 | val isNotConfirmed = exception is DisabledException 23 | if (isNotConfirmed) { 24 | concreteError = "login.non_confirmed_user" 25 | } 26 | 27 | if (error == "confirmation_link_was_already_used") { 28 | concreteError = "login.confirmation_link_was_already_used" 29 | } 30 | 31 | model.addAttribute("nonConfirmedUser", isNotConfirmed) 32 | model.addAttribute("error", concreteError) 33 | 34 | return "auth/login.html" 35 | } 36 | 37 | @RequestMapping("/login", params = ["logout"]) 38 | fun signOutSuccess(model: Model): String { 39 | model.addAttribute("signOutInfo", "login.successful_sign_out") 40 | model.addAttribute("nonConfirmedUser", false) 41 | return "auth/login.html" 42 | } 43 | 44 | @RequestMapping("/login") 45 | fun login(): String { 46 | return "auth/login.html" 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /src/main/kotlin/app/auth/signup/CodeUsedAlreadyException.kt: -------------------------------------------------------------------------------- 1 | package app.auth.signup 2 | 3 | class CodeUsedAlreadyException 4 | : RuntimeException("Confirmation code already used") 5 | -------------------------------------------------------------------------------- /src/main/kotlin/app/auth/signup/ConfirmController.kt: -------------------------------------------------------------------------------- 1 | package app.auth.signup 2 | 3 | import app.auth.AuthService 4 | import app.auth.user.UserNotFoundException 5 | import app.util.getBaseUrlFrom 6 | import org.springframework.http.HttpStatus 7 | import org.springframework.stereotype.Controller 8 | import org.springframework.web.bind.annotation.* 9 | import javax.servlet.http.HttpServletRequest 10 | 11 | @Controller 12 | class ConfirmController(private val authService: AuthService, 13 | private val forceLoginService: ForceLoginService) { 14 | 15 | @GetMapping("/confirm/{code}") 16 | fun confirm(@PathVariable code: String): String { 17 | return try { 18 | val user = authService.confirmAndGetUser(code) 19 | forceLoginService.loginUserAfterConfirmation(user.email) 20 | "redirect:/dashboard" 21 | } catch (e: InvalidCodeException) { 22 | "auth/invalid_confirmation_link.html" 23 | } catch (e: CodeUsedAlreadyException) { 24 | "redirect:/login?error=confirmation_link_was_already_used" 25 | } 26 | } 27 | 28 | @PostMapping("/resend-confirmation") 29 | fun resendConfirmation(@RequestParam username: String, 30 | httpRequest: HttpServletRequest): String { 31 | val baseUrl = getBaseUrlFrom(httpRequest) 32 | authService.resendConfirmation(username, baseUrl) 33 | return "redirect:/thank-you" 34 | } 35 | 36 | @ExceptionHandler(UserNotFoundException::class) 37 | @ResponseStatus(HttpStatus.NOT_FOUND) 38 | fun handleUserNotFoundException() = Unit 39 | } 40 | -------------------------------------------------------------------------------- /src/main/kotlin/app/auth/signup/ConfirmationLink.kt: -------------------------------------------------------------------------------- 1 | package app.auth.signup 2 | 3 | data class ConfirmationLink(val href: String, 4 | val code: String) 5 | -------------------------------------------------------------------------------- /src/main/kotlin/app/auth/signup/ConfirmationLinkService.kt: -------------------------------------------------------------------------------- 1 | package app.auth.signup 2 | 3 | import org.springframework.stereotype.Service 4 | import org.springframework.web.util.UriComponentsBuilder 5 | import java.lang.Character.MAX_RADIX 6 | import java.math.BigInteger 7 | import java.security.SecureRandom 8 | 9 | @Service 10 | class ConfirmationLinkService { 11 | private val secureRandom = SecureRandom() 12 | 13 | fun generate(baseUrl: String): ConfirmationLink { 14 | val code = generateCode() 15 | 16 | val href = UriComponentsBuilder.fromHttpUrl(baseUrl) 17 | .pathSegment("confirm") 18 | .pathSegment(code) 19 | .build() 20 | .toUriString() 21 | 22 | return ConfirmationLink(code = code, href = href) 23 | } 24 | 25 | // This is not easy to test. Right now it is tested that it generates 26 | // unique value every time only. 27 | private fun generateCode() = 28 | BigInteger(256, secureRandom).toString(MAX_RADIX) 29 | } 30 | -------------------------------------------------------------------------------- /src/main/kotlin/app/auth/signup/ForceLoginService.kt: -------------------------------------------------------------------------------- 1 | package app.auth.signup 2 | 3 | import app.auth.SecurityContextWrapper 4 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken 5 | import org.springframework.security.core.userdetails.UserDetailsService 6 | import org.springframework.stereotype.Service 7 | 8 | @Service 9 | class ForceLoginService(private val userDetailsService: UserDetailsService, 10 | private val securityContextWrapper: SecurityContextWrapper) { 11 | 12 | // use this only when you know what you’re doing 13 | fun loginUserAfterConfirmation(email: String) { 14 | val userDetails = userDetailsService.loadUserByUsername(email) 15 | val authentication = UsernamePasswordAuthenticationToken( 16 | userDetails.username, 17 | userDetails.password, 18 | userDetails.authorities 19 | ) 20 | securityContextWrapper.authentication = authentication 21 | } 22 | } -------------------------------------------------------------------------------- /src/main/kotlin/app/auth/signup/InvalidCodeException.kt: -------------------------------------------------------------------------------- 1 | package app.auth.signup 2 | 3 | class InvalidCodeException 4 | : RuntimeException("Invalid confirmation code provided") 5 | -------------------------------------------------------------------------------- /src/main/kotlin/app/auth/signup/SignupController.kt: -------------------------------------------------------------------------------- 1 | package app.auth.signup 2 | 3 | import app.auth.AuthService 4 | import app.auth.user.User 5 | import app.auth.user.UserExistsAlreadyException 6 | import app.util.TimeProvider 7 | import app.util.getBaseUrlFrom 8 | import org.springframework.stereotype.Controller 9 | import org.springframework.ui.Model 10 | import org.springframework.validation.BindingResult 11 | import org.springframework.web.bind.annotation.GetMapping 12 | import org.springframework.web.bind.annotation.ModelAttribute 13 | import org.springframework.web.bind.annotation.PostMapping 14 | import javax.servlet.http.HttpServletRequest 15 | import javax.validation.* 16 | import javax.validation.constraints.Email 17 | import javax.validation.constraints.NotBlank 18 | import javax.validation.constraints.Size 19 | import kotlin.reflect.KClass 20 | 21 | @Controller 22 | class SignupController(private val authService: AuthService, 23 | private val timeProvider: TimeProvider) { 24 | 25 | @GetMapping("/signup") 26 | fun signupForm(model: Model): String { 27 | model.addAttribute("form", SignupRequest()) 28 | return "auth/signup.html" 29 | } 30 | 31 | @PostMapping("/signup") 32 | fun signup(@Valid @ModelAttribute("form") form: SignupRequest, 33 | bindingResult: BindingResult, 34 | model: Model, 35 | httpRequest: HttpServletRequest): String { 36 | 37 | if (bindingResult.hasErrors()) { 38 | return "auth/signup.html" 39 | } 40 | 41 | val user = User( 42 | email = form.username, 43 | name = form.name, 44 | password = form.pass, 45 | createdAt = timeProvider.now(), 46 | updatedAt = timeProvider.now() 47 | ) 48 | val baseUrl = getBaseUrlFrom(httpRequest) 49 | 50 | try { 51 | authService.signupUser(user, baseUrl) 52 | } catch (e: UserExistsAlreadyException) { 53 | model.addAttribute("error", "signup.user_already_taken") 54 | return "auth/signup.html" 55 | } 56 | 57 | return "redirect:/thank-you" 58 | } 59 | 60 | @GetMapping("/thank-you") 61 | fun thankYou() = "auth/thank_you.html" 62 | 63 | } 64 | 65 | @PasswordsMatch 66 | data class SignupRequest(@field:NotBlank(message = "{validation.not_blank}") 67 | @field:Email(message = "{validation.valid_email}") 68 | val username: String = "", 69 | 70 | @field:NotBlank(message = "{validation.not_blank}") 71 | val name: String = "", 72 | 73 | @field:Size(min = 10, message = "{validation.password_min_size}") 74 | val pass: String = "", 75 | 76 | val confirm: String = "") 77 | 78 | class PasswordsMatchValidator : ConstraintValidator { 79 | private lateinit var message: String 80 | 81 | override fun initialize(constraintAnnotation: PasswordsMatch) { 82 | message = constraintAnnotation.message 83 | } 84 | 85 | override fun isValid(value: SignupRequest, context: ConstraintValidatorContext): Boolean { 86 | if (value.pass == value.confirm) return true 87 | 88 | context.disableDefaultConstraintViolation() 89 | 90 | context.buildConstraintViolationWithTemplate(message) 91 | .addPropertyNode("pass") 92 | .addConstraintViolation() 93 | 94 | context.buildConstraintViolationWithTemplate(message) 95 | .addPropertyNode("confirm") 96 | .addConstraintViolation() 97 | 98 | return false 99 | } 100 | } 101 | 102 | @MustBeDocumented 103 | @Constraint(validatedBy = [PasswordsMatchValidator::class]) 104 | @Target(AnnotationTarget.CLASS) 105 | @Retention(AnnotationRetention.RUNTIME) 106 | annotation class PasswordsMatch( 107 | val message: String = "{validation.passwords_match}", 108 | val groups: Array> = [], 109 | val payload: Array> = [] 110 | ) 111 | -------------------------------------------------------------------------------- /src/main/kotlin/app/auth/user/User.kt: -------------------------------------------------------------------------------- 1 | package app.auth.user 2 | 3 | import java.time.LocalDateTime 4 | 5 | data class User(val id: Long? = null, 6 | val confirmed: Boolean = false, 7 | val confirmationCode: String? = null, 8 | val email: String, 9 | val password: String, 10 | val isPasswordEncoded: Boolean = false, 11 | val name: String, 12 | val createdAt: LocalDateTime, 13 | val updatedAt: LocalDateTime) { 14 | 15 | companion object 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/app/auth/user/UserExistsAlreadyException.kt: -------------------------------------------------------------------------------- 1 | package app.auth.user 2 | 3 | class UserExistsAlreadyException 4 | : RuntimeException("User already exist") 5 | -------------------------------------------------------------------------------- /src/main/kotlin/app/auth/user/UserNotFoundException.kt: -------------------------------------------------------------------------------- 1 | package app.auth.user 2 | 3 | class UserNotFoundException 4 | : RuntimeException("User not found") 5 | -------------------------------------------------------------------------------- /src/main/kotlin/app/auth/user/UserRepository.kt: -------------------------------------------------------------------------------- 1 | package app.auth.user 2 | 3 | import org.springframework.jdbc.core.JdbcTemplate 4 | import org.springframework.jdbc.core.RowMapper 5 | import org.springframework.security.crypto.password.PasswordEncoder 6 | import org.springframework.stereotype.Repository 7 | 8 | @Repository 9 | class UserRepository(private val jdbcTemplate: JdbcTemplate, 10 | private val passwordEncoder: PasswordEncoder) { 11 | 12 | companion object { 13 | val usersByUsernameQuery = """ 14 | select email as username, password, confirmed as enabled 15 | from users where email = ? 16 | """.trimIndent() 17 | 18 | val authoritiesByUsernameQuery = """ 19 | select (select ?) as username, (select 'user') as role 20 | """.trimIndent() 21 | } 22 | 23 | fun create(user: User) { 24 | jdbcTemplate.update(""" 25 | insert into 26 | users( 27 | email, 28 | password, 29 | name, 30 | confirmed, 31 | confirmation_code, 32 | created_at, 33 | updated_at 34 | ) 35 | values(?, ?, ?, ?, ?, ?, ?) 36 | """.trimIndent(), 37 | user.email, 38 | passwordEncoder.encode(user.password), 39 | user.name, 40 | user.confirmed, 41 | user.confirmationCode, 42 | user.createdAt, 43 | user.updatedAt 44 | ) 45 | } 46 | 47 | fun findAll(): List = 48 | jdbcTemplate.query(""" 49 | select * from users 50 | """.trimIndent(), rowMapper) 51 | 52 | fun findByEmail(email: String): User? = 53 | jdbcTemplate.query(""" 54 | select * from users where email = ? 55 | """.trimIndent(), rowMapper, email) 56 | .firstOrNull() 57 | 58 | fun findByConfirmationCode(confirmationCode: String): User? = 59 | jdbcTemplate.query(""" 60 | select * from users where confirmation_code = ? 61 | """.trimIndent(), rowMapper, confirmationCode) 62 | .firstOrNull() 63 | 64 | fun confirm(user: User) { 65 | jdbcTemplate.update(""" 66 | update users set confirmed = true where id = ? 67 | """.trimIndent(), user.id) 68 | } 69 | 70 | fun updateConfirmationCode(user: User) { 71 | jdbcTemplate.update(""" 72 | update users set confirmation_code = ? where id = ? 73 | """.trimIndent(), user.confirmationCode, user.id) 74 | } 75 | 76 | private val rowMapper: RowMapper = RowMapper { rs, _ -> 77 | User( 78 | id = rs.getLong("id"), 79 | email = rs.getString("email"), 80 | password = rs.getString("password"), 81 | isPasswordEncoded = true, 82 | name = rs.getString("name"), 83 | confirmed = rs.getBoolean("confirmed"), 84 | confirmationCode = rs.getString("confirmation_code"), 85 | createdAt = rs.getTimestamp("created_at").toLocalDateTime(), 86 | updatedAt = rs.getTimestamp("updated_at").toLocalDateTime() 87 | ) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/main/kotlin/app/config/ThymeleafConfig.kt: -------------------------------------------------------------------------------- 1 | package app.config 2 | 3 | import nz.net.ultraq.thymeleaf.LayoutDialect 4 | import org.springframework.context.annotation.Bean 5 | import org.springframework.context.annotation.Configuration 6 | 7 | @Configuration 8 | class ThymeleafConfig { 9 | @Bean 10 | fun layoutDialect() = LayoutDialect() 11 | } -------------------------------------------------------------------------------- /src/main/kotlin/app/config/WebMvcConfig.kt: -------------------------------------------------------------------------------- 1 | package app.config 2 | 3 | import org.springframework.beans.factory.annotation.Autowired 4 | import org.springframework.context.MessageSource 5 | import org.springframework.context.annotation.Bean 6 | import org.springframework.context.annotation.Configuration 7 | import org.springframework.validation.Validator 8 | import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean 9 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer 10 | 11 | @Configuration 12 | class WebMvcConfig : WebMvcConfigurer { 13 | 14 | @Autowired 15 | private lateinit var messageSource: MessageSource 16 | 17 | @Bean 18 | override fun getValidator(): Validator = 19 | LocalValidatorFactoryBean().apply { 20 | setValidationMessageSource(messageSource) 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/main/kotlin/app/email/DevEmailService.kt: -------------------------------------------------------------------------------- 1 | package app.email 2 | 3 | import org.springframework.context.annotation.Profile 4 | import org.springframework.stereotype.Service 5 | 6 | @Profile("dev") 7 | @Service 8 | class DevEmailService : EmailService { 9 | override fun send(message: EmailMessage) { 10 | println("from: ${message.from}") 11 | println("to: ${message.to}") 12 | println("subject: ${message.subject}") 13 | println(message.textBody) 14 | } 15 | } -------------------------------------------------------------------------------- /src/main/kotlin/app/email/EmailMessage.kt: -------------------------------------------------------------------------------- 1 | package app.email 2 | 3 | data class EmailMessage(val from: String, 4 | val to: String, 5 | val subject: String, 6 | val htmlBody: String?, 7 | val textBody: String) 8 | -------------------------------------------------------------------------------- /src/main/kotlin/app/email/EmailService.kt: -------------------------------------------------------------------------------- 1 | package app.email 2 | 3 | interface EmailService { 4 | fun send(message: EmailMessage) 5 | } 6 | -------------------------------------------------------------------------------- /src/main/kotlin/app/email/EmailTemplate.kt: -------------------------------------------------------------------------------- 1 | package app.email 2 | 3 | import org.springframework.context.i18n.LocaleContextHolder 4 | import org.springframework.stereotype.Service 5 | import org.thymeleaf.TemplateEngine 6 | import org.thymeleaf.context.Context 7 | 8 | @Service 9 | class EmailTemplate(private val templateEngine: TemplateEngine, 10 | private val emailService: EmailService) { 11 | 12 | fun send(from: String, 13 | to: String, 14 | subject: String, 15 | html: String, 16 | text: String, 17 | context: Map) { 18 | 19 | val locale = LocaleContextHolder.getLocale() 20 | val ctx = Context(locale, context) 21 | 22 | val textBody = templateEngine.process(text, ctx) 23 | val htmlBody = templateEngine.process(html, ctx) 24 | 25 | emailService.send(EmailMessage(from, to, subject, htmlBody, textBody)) 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/main/kotlin/app/email/RealEmailService.kt: -------------------------------------------------------------------------------- 1 | package app.email 2 | 3 | import org.springframework.context.annotation.Profile 4 | import org.springframework.mail.javamail.JavaMailSender 5 | import org.springframework.mail.javamail.MimeMessageHelper 6 | import org.springframework.stereotype.Service 7 | 8 | @Profile("cloud") 9 | @Service 10 | class RealEmailService(private val mailSender: JavaMailSender) : EmailService { 11 | 12 | override fun send(message: EmailMessage) { 13 | 14 | mailSender.send( 15 | mailSender.createMimeMessage(message.htmlBody != null) { 16 | setFrom(message.from) 17 | setTo(message.to) 18 | setSubject(message.subject) 19 | 20 | if (message.htmlBody != null) { 21 | setText(message.textBody, message.htmlBody) 22 | } else { 23 | setText(message.textBody) 24 | } 25 | } 26 | ) 27 | } 28 | 29 | private fun JavaMailSender.createMimeMessage(multipart: Boolean, block: MimeMessageHelper.() -> Unit) = 30 | MimeMessageHelper(createMimeMessage(), multipart) 31 | .apply(block) 32 | .mimeMessage 33 | } -------------------------------------------------------------------------------- /src/main/kotlin/app/quiz/CreateQuizRequest.kt: -------------------------------------------------------------------------------- 1 | package app.quiz 2 | 3 | data class CreateQuizRequest( 4 | val userId: Long, 5 | val title: String, 6 | val image: ByteArray, 7 | val description: String, 8 | val durationInMinutes: Int, 9 | val cta: String 10 | ) 11 | -------------------------------------------------------------------------------- /src/main/kotlin/app/quiz/Quiz.kt: -------------------------------------------------------------------------------- 1 | package app.quiz 2 | 3 | import java.time.LocalDateTime 4 | 5 | data class Quiz(val id: Long? = null, 6 | val userId: Long, 7 | val title: String, 8 | val imageUrl: String, 9 | val description: String, 10 | val durationInMinutes: Int, 11 | val cta: String, 12 | val createdAt: LocalDateTime, 13 | val updatedAt: LocalDateTime) { 14 | 15 | companion object 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/app/quiz/QuizController.kt: -------------------------------------------------------------------------------- 1 | package app.quiz 2 | 3 | import app.auth.AuthService 4 | import org.springframework.http.HttpStatus 5 | import org.springframework.stereotype.Controller 6 | import org.springframework.ui.Model 7 | import org.springframework.validation.BindingResult 8 | import org.springframework.web.bind.annotation.* 9 | import org.springframework.web.multipart.MultipartFile 10 | import java.security.Principal 11 | import javax.validation.* 12 | import javax.validation.constraints.Min 13 | import javax.validation.constraints.NotBlank 14 | import kotlin.reflect.KClass 15 | 16 | @Controller 17 | class QuizController(private val quizService: QuizService, 18 | private val authService: AuthService) { 19 | 20 | @GetMapping("/quizzes/new") 21 | fun newQuiz(model: Model): String { 22 | model.addAttribute("form", QuizForm()) 23 | return "quizzes/new.html" 24 | } 25 | 26 | @GetMapping("/quizzes") 27 | fun listQuizzes(model: Model, principal: Principal): String { 28 | val currentUser = authService.getCurrentUser(principal.name) 29 | 30 | val quizzes = quizService.getMostRecentQuizzesForUser(currentUser) 31 | 32 | model.addAttribute("quizzes", quizzes) 33 | return "quizzes/list.html" 34 | } 35 | 36 | @PostMapping("/quizzes") 37 | fun createQuiz(@Valid @ModelAttribute("form") form: QuizForm, 38 | bindingResult: BindingResult, 39 | principal: Principal): String { 40 | 41 | if (bindingResult.hasErrors()) { 42 | return "quizzes/new.html" 43 | } 44 | 45 | val currentUser = authService.getCurrentUser(principal.name) 46 | 47 | quizService.createQuiz(CreateQuizRequest( 48 | userId = currentUser.id, 49 | title = form.title, 50 | image = form.image!!.bytes, 51 | description = form.description, 52 | durationInMinutes = form.duration!!, 53 | cta = form.cta 54 | )) 55 | 56 | return "redirect:/quizzes" 57 | } 58 | 59 | @GetMapping("/quizzes/{id}/edit") 60 | fun editQuiz(@PathVariable id: Long, 61 | principal: Principal, 62 | model: Model): String { 63 | val currentUser = authService.getCurrentUser(principal.name) 64 | 65 | val quiz = quizService.getQuizForEditing(id, currentUser) 66 | 67 | model.addAttribute("form", EditQuizForm( 68 | id = quiz.id, 69 | title = quiz.title, 70 | currentImageUrl = quiz.imageUrl, 71 | description = quiz.description, 72 | duration = quiz.durationInMinutes, 73 | cta = quiz.cta 74 | )) 75 | 76 | return "quizzes/edit.html" 77 | } 78 | 79 | @ExceptionHandler(QuizNotFoundException::class) 80 | @ResponseStatus(HttpStatus.NOT_FOUND) 81 | fun handleQuizNotFound() = Unit 82 | 83 | } 84 | 85 | data class QuizForm( 86 | @field:NotBlank(message = "{validation.not_blank}") 87 | val title: String = "", 88 | 89 | @field:NotBlank(message = "{validation.not_blank}") 90 | val description: String = "", 91 | 92 | @field:Min(value = 1, message = "{validation.not_blank}") 93 | val duration: Int? = -1, 94 | 95 | @field:NotBlank(message = "{validation.not_blank}") 96 | val cta: String = "", 97 | 98 | @field:UploadNotBlank 99 | var image: MultipartFile? = null 100 | ) 101 | 102 | data class EditQuizForm( 103 | val id: Long? = null, 104 | val title: String = "", 105 | val description: String = "", 106 | val duration: Int? = -1, 107 | val cta: String = "", 108 | val currentImageUrl: String = "", 109 | var image: MultipartFile? = null 110 | ) 111 | 112 | class UploadNotBlankValidator : ConstraintValidator { 113 | override fun isValid(value: MultipartFile?, context: ConstraintValidatorContext?): Boolean { 114 | return value != null && !value.isEmpty 115 | } 116 | } 117 | 118 | @MustBeDocumented 119 | @Constraint(validatedBy = [UploadNotBlankValidator::class]) 120 | @Target(AnnotationTarget.FIELD) 121 | @Retention(AnnotationRetention.RUNTIME) 122 | annotation class UploadNotBlank( 123 | val message: String = "{validation.not_blank}", 124 | val groups: Array> = [], 125 | val payload: Array> = [] 126 | ) 127 | -------------------------------------------------------------------------------- /src/main/kotlin/app/quiz/QuizNotFoundException.kt: -------------------------------------------------------------------------------- 1 | package app.quiz 2 | 3 | class QuizNotFoundException : RuntimeException("Quiz was not found") 4 | -------------------------------------------------------------------------------- /src/main/kotlin/app/quiz/QuizRepository.kt: -------------------------------------------------------------------------------- 1 | package app.quiz 2 | 3 | import org.springframework.jdbc.core.JdbcTemplate 4 | import org.springframework.jdbc.core.RowMapper 5 | import org.springframework.stereotype.Repository 6 | 7 | @Repository 8 | class QuizRepository(private val jdbcTemplate: JdbcTemplate) { 9 | fun create(quiz: Quiz) { 10 | jdbcTemplate.update(""" 11 | insert into 12 | quizzes ( 13 | user_id, 14 | title, 15 | image_url, 16 | description, 17 | duration_in_minutes, 18 | cta, 19 | created_at, 20 | updated_at 21 | ) 22 | values (?, ?, ?, ?, ?, ?, ?, ?) 23 | """.trimIndent(), 24 | quiz.userId, 25 | quiz.title, 26 | quiz.imageUrl, 27 | quiz.description, 28 | quiz.durationInMinutes, 29 | quiz.cta, 30 | quiz.createdAt, 31 | quiz.updatedAt 32 | ) 33 | } 34 | 35 | fun findAll(): List { 36 | return jdbcTemplate.query(""" 37 | select * from quizzes 38 | """.trimIndent(), toQuiz) 39 | } 40 | 41 | fun findMostRecentByUserId(userId: Long): List { 42 | return jdbcTemplate.query(""" 43 | select * from quizzes where user_id = ? order by created_at desc 44 | """.trimIndent(), toQuiz, userId) 45 | } 46 | 47 | fun findByIdAndUserId(id: Long, userId: Long): Quiz? { 48 | return jdbcTemplate.query(""" 49 | select * from quizzes where id = ? and user_id = ? 50 | """.trimIndent(), toQuiz, id, userId) 51 | .firstOrNull() 52 | } 53 | 54 | private val toQuiz = RowMapper { rs, _ -> 55 | Quiz( 56 | id = rs.getLong("id"), 57 | userId = rs.getLong("user_id"), 58 | title = rs.getString("title"), 59 | imageUrl = rs.getString("image_url"), 60 | description = rs.getString("description"), 61 | durationInMinutes = rs.getInt("duration_in_minutes"), 62 | cta = rs.getString("cta"), 63 | createdAt = rs.getTimestamp("created_at").toLocalDateTime(), 64 | updatedAt = rs.getTimestamp("updated_at").toLocalDateTime() 65 | ) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/main/kotlin/app/quiz/QuizService.kt: -------------------------------------------------------------------------------- 1 | package app.quiz 2 | 3 | import app.auth.CurrentUser 4 | import app.quiz.images.ImageRepository 5 | import app.quiz.images.ImageUploadException 6 | import app.util.TimeProvider 7 | import org.springframework.stereotype.Service 8 | 9 | @Service 10 | class QuizService(private val quizRepository: QuizRepository, 11 | private val imageRepository: ImageRepository, 12 | private val timeProvider: TimeProvider) { 13 | 14 | fun createQuiz(request: CreateQuizRequest) { 15 | val imageUrl = uploadImage(request.image) 16 | 17 | val quiz = Quiz( 18 | userId = request.userId, 19 | title = request.title, 20 | imageUrl = imageUrl, 21 | description = request.description, 22 | durationInMinutes = request.durationInMinutes, 23 | cta = request.cta, 24 | createdAt = timeProvider.now(), 25 | updatedAt = timeProvider.now() 26 | ) 27 | 28 | quizRepository.create(quiz) 29 | } 30 | 31 | private fun uploadImage(image: ByteArray): String { 32 | try { 33 | return imageRepository.upload(image) 34 | } catch (e: Exception) { 35 | throw ImageUploadException(e) 36 | } 37 | } 38 | 39 | fun getMostRecentQuizzesForUser(currentUser: CurrentUser): List { 40 | return quizRepository.findMostRecentByUserId(currentUser.id) 41 | } 42 | 43 | fun getQuizForEditing(id: Long, currentUser: CurrentUser): Quiz { 44 | return quizRepository.findByIdAndUserId(id = id, userId = currentUser.id) 45 | ?: throw QuizNotFoundException() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/kotlin/app/quiz/images/FileSystemImageRepository.kt: -------------------------------------------------------------------------------- 1 | package app.quiz.images 2 | 3 | import app.util.UuidProvider 4 | import org.springframework.context.annotation.Profile 5 | import org.springframework.stereotype.Service 6 | import java.io.File 7 | import java.nio.file.Files 8 | import java.nio.file.Paths 9 | 10 | @Profile("dev", "test") 11 | @Service 12 | class FileSystemImageRepository( 13 | private val uuidProvider: UuidProvider) : ImageRepository { 14 | 15 | override fun upload(image: ByteArray): String { 16 | val uuid = uuidProvider.generateUuid() 17 | 18 | val prefix = uuid.substring(0..1) 19 | val context = "uploads/$prefix" 20 | 21 | val name = uuid.substring(2) 22 | val extension = "png" 23 | 24 | Files.createDirectories(Paths.get("./$context")) 25 | 26 | val fileName = "$name.$extension" 27 | val path = "./$context/$fileName" 28 | File(path).writeBytes(image) 29 | 30 | return "/$context/$fileName" 31 | } 32 | 33 | } -------------------------------------------------------------------------------- /src/main/kotlin/app/quiz/images/ImageController.kt: -------------------------------------------------------------------------------- 1 | package app.quiz.images 2 | 3 | import org.apache.tomcat.util.http.fileupload.IOUtils 4 | import org.springframework.context.annotation.Profile 5 | import org.springframework.http.HttpHeaders.CONTENT_DISPOSITION 6 | import org.springframework.stereotype.Controller 7 | import org.springframework.web.bind.annotation.GetMapping 8 | import org.springframework.web.bind.annotation.PathVariable 9 | import java.io.File 10 | import java.io.InputStream 11 | import javax.servlet.http.HttpServletResponse 12 | 13 | @Profile("dev", "test") 14 | @Controller 15 | class ImageController { 16 | 17 | @GetMapping("/uploads/{prefix}/{image}") 18 | fun downloadImage(@PathVariable prefix: String, 19 | @PathVariable image: String, 20 | response: HttpServletResponse) { 21 | 22 | val input: InputStream = File("./uploads/$prefix/$image").inputStream() 23 | 24 | response.addHeader(CONTENT_DISPOSITION, "attachment;filename=$image") 25 | response.contentType = "image/*" 26 | 27 | IOUtils.copy(input, response.outputStream) 28 | response.flushBuffer() 29 | } 30 | 31 | } -------------------------------------------------------------------------------- /src/main/kotlin/app/quiz/images/ImageRepository.kt: -------------------------------------------------------------------------------- 1 | package app.quiz.images 2 | 3 | interface ImageRepository { 4 | fun upload(image: ByteArray): String 5 | } 6 | -------------------------------------------------------------------------------- /src/main/kotlin/app/quiz/images/ImageUploadException.kt: -------------------------------------------------------------------------------- 1 | package app.quiz.images 2 | 3 | class ImageUploadException(cause: Throwable?) 4 | : RuntimeException(cause) 5 | -------------------------------------------------------------------------------- /src/main/kotlin/app/util/TimeProvider.kt: -------------------------------------------------------------------------------- 1 | package app.util 2 | 3 | import org.springframework.stereotype.Service 4 | import java.time.LocalDateTime 5 | 6 | @Service 7 | class TimeProvider { 8 | fun now() = LocalDateTime.now() 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/app/util/UuidProvider.kt: -------------------------------------------------------------------------------- 1 | package app.util 2 | 3 | import org.springframework.stereotype.Service 4 | import java.util.* 5 | 6 | @Service 7 | class UuidProvider { 8 | fun generateUuid() = UUID.randomUUID().toString() 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/app/util/getBaseUrlFrom.kt: -------------------------------------------------------------------------------- 1 | package app.util 2 | 3 | import org.springframework.web.util.UriComponentsBuilder 4 | import javax.servlet.http.HttpServletRequest 5 | 6 | fun getBaseUrlFrom(httpRequest: HttpServletRequest) = UriComponentsBuilder 7 | .fromHttpUrl(httpRequest.requestURL.toString()) 8 | .replacePath(httpRequest.contextPath) 9 | .build() 10 | .toUriString() -------------------------------------------------------------------------------- /src/main/resources/application-cloud.yml.example: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | url: 4 | username: 5 | password: 6 | 7 | mail: 8 | host: 9 | port: 10 | username: 11 | password: 12 | 13 | app: 14 | auth: 15 | confirmation-emails: 16 | from: "Your Name " -------------------------------------------------------------------------------- /src/main/resources/application-dev.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | thymeleaf: 3 | prefix: file:src/main/resources/templates/ 4 | cache: false 5 | 6 | resources: 7 | static-locations: file:src/main/resources/static/ 8 | cache: 9 | period: 0 -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | url: jdbc:postgresql://localhost/quizzy 4 | username: quizzy 5 | password: quizzy 6 | driver-class-name: org.postgresql.Driver 7 | 8 | messages: 9 | basename: translations/messages 10 | 11 | mail: 12 | test-connection: true 13 | properties: 14 | mail: 15 | smtp: 16 | auth: true 17 | starttls: 18 | enable: true 19 | connectiontimeout: 5000 20 | timeout: 3000 21 | writetimeout: 5000 22 | 23 | app: 24 | auth: 25 | confirmation-emails: 26 | from: "Quizzy Support " 27 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V1__Create_serial_sequence.sql: -------------------------------------------------------------------------------- 1 | create sequence "serial"; -------------------------------------------------------------------------------- /src/main/resources/db/migration/V2__Add_users_table.sql: -------------------------------------------------------------------------------- 1 | create table users ( 2 | "id" bigint not null unique primary key default nextval('serial'), 3 | "email" varchar(50) not null unique, 4 | "password" varchar(100) not null, 5 | "name" varchar(80) not null, 6 | "confirmed" boolean not null default false, 7 | "confirmation_code" varchar(60) not null unique, 8 | "created_at" timestamp without time zone not null, 9 | "updated_at" timestamp without time zone not null 10 | ); -------------------------------------------------------------------------------- /src/main/resources/db/migration/V3__Add_quizzes_table.sql: -------------------------------------------------------------------------------- 1 | create table quizzes ( 2 | "id" bigint not null unique primary key default nextval('serial'), 3 | "user_id" bigint not null references users(id), 4 | "title" varchar(130) not null, 5 | "image_url" text not null, 6 | "description" text not null, 7 | "duration_in_minutes" int not null, 8 | "cta" varchar(50) not null, 9 | "created_at" timestamp without time zone not null, 10 | "updated_at" timestamp without time zone not null 11 | ); -------------------------------------------------------------------------------- /src/main/resources/static: -------------------------------------------------------------------------------- 1 | ../../../frontend/static -------------------------------------------------------------------------------- /src/main/resources/templates/auth/dashboard.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | Dashboard title 7 | 8 | 9 | 10 |

Dashboard Header

11 | 12 |
13 |
14 |
15 | Welcome 16 | Username 17 |
18 | 19 |
20 | 21 |
22 |
23 | 24 |
25 |
26 | Quiz list 27 |
28 |
29 |
30 | 31 | 32 | -------------------------------------------------------------------------------- /src/main/resources/templates/auth/invalid_confirmation_link.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | Invalid code title 7 | 8 | 9 | 10 |
13 | code is invalid 14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /src/main/resources/templates/auth/login.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | Login Page Title 7 | 8 | 9 | 10 |

Login Header

11 | 12 |
13 |
Sign out info
14 | 15 |
16 | Login Error 17 | 18 |
19 |
21 | 22 | 23 | Resend Confirmation 26 |
27 |
28 |
29 | 30 |
31 |
32 | 33 | 35 |
36 | 37 |
38 | 39 | 41 |
42 | 43 |
44 | 47 |
48 | 49 |
50 | No account yet? 51 | Create Account 52 |
53 | 54 |
55 |
56 | 57 | 58 | -------------------------------------------------------------------------------- /src/main/resources/templates/auth/signup.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | Create account title 7 | 8 | 9 | 10 |

Create Account Header

11 | 12 |
13 |
14 | Error message 15 |
16 | 17 |
18 | 19 |
20 | 21 | 23 |

24 | Username 25 | must be valid 26 |

27 |
28 | 29 |
30 | 31 | 33 |

34 | Name 35 | must be valid 36 |

37 |
38 | 39 |
40 | 41 | 43 |

44 | Passwords 45 | must be valid 46 |

47 |
48 | 49 |
50 | 51 | 53 |

54 | Passwords 55 | must be valid 56 |

57 |
58 | 59 |
60 | 64 |
65 | 66 |
67 | Have an account already? 68 | Login 69 |
70 | 71 |
72 |
73 | 74 | 75 | -------------------------------------------------------------------------------- /src/main/resources/templates/auth/thank_you.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | Thank you title 7 | 8 | 9 | 10 |

Thank you header

11 | 12 |
14 | code is invalid 15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /src/main/resources/templates/emails/confirmation.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

Hey, Friend!

5 | 6 |

To complete your signup, please click on the following link:

7 | 8 |

Confirm my account

9 | 10 |

Or copy and paste the following link into your browser: 11 | http://example.org/confirm-url

12 | 13 |

Thank you,
14 | example.org

15 | 16 | -------------------------------------------------------------------------------- /src/main/resources/templates/emails/confirmation.txt: -------------------------------------------------------------------------------- 1 | Hey, [( ${name} )]! 2 | 3 | To complete your signup, please verify your account by copying and pasting the following link into your browser: 4 | 5 | [( ${confirmUrl} )] 6 | 7 | Thank you, 8 | example.org -------------------------------------------------------------------------------- /src/main/resources/templates/layouts/_common.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 8 | 9 | Example Title 10 | 11 | 12 | 13 | 14 |
15 |
16 |
17 |
18 |

Example header

19 |
20 |
21 |
22 | 23 |
24 |
25 |
26 |

Example Content

27 |
28 |
29 |
30 | 31 |
32 |
33 |
34 |

© Company From-Until

35 |
36 |
37 |
38 |
39 | 40 | 41 | -------------------------------------------------------------------------------- /src/main/resources/templates/layouts/default.html: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /src/main/resources/templates/layouts/narrow.html: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /src/main/resources/templates/quizzes/_form.html: -------------------------------------------------------------------------------- 1 |
4 | 5 |
6 |
7 | 8 | 10 |

11 | Title 12 | must not be blank 13 |

14 |
15 | 16 |
17 |
18 | 19 | 23 |
24 |
25 | 26 |
27 | 28 | 30 |

31 | Image 32 | must not be blank 33 |

34 |
35 | 36 |
37 | 38 | 44 |

45 | Description 46 | must not be blank 47 |

48 |
49 | 50 |
51 | 52 | 66 |

67 | Duration 68 | must not be blank 69 |

70 |
71 | 72 |
73 | 74 | 76 |

77 | CTA 78 | must not be blank 79 |

80 |
81 | 82 |
83 | 87 |
88 |
89 |
-------------------------------------------------------------------------------- /src/main/resources/templates/quizzes/edit.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | Edit Quiz Title 7 | 8 | 9 | 10 |

Edit Quiz Header

11 | 12 |
14 | Update Form 15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /src/main/resources/templates/quizzes/list.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | Quiz list title 7 | 8 | 9 | 10 |

Quiz List Header

11 | 12 |
13 |
14 |
15 | New quiz 19 |
20 |
21 | 22 |
23 |
24 |
28 | 29 |
30 | Edit 35 |
36 | 37 |

Quiz title

38 | 39 | Quiz image 44 | 45 |

46 | Quiz description here 47 |

48 | 49 | Take quiz 54 | 55 | 56 | 57 | It will take you only 3 minutes! 58 | 59 | 60 |
61 |
62 |
63 |
64 | 65 | 66 | -------------------------------------------------------------------------------- /src/main/resources/templates/quizzes/new.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | New Quiz Title 7 | 8 | 9 | 10 |

New Quiz Header

11 | 12 |
14 | Create Form 15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /src/main/resources/translations/messages.properties: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waterlink/kotlin-spring-boot-mvc-starter/e4968ba369ce40258e9d3c069d918430fef015c0/src/main/resources/translations/messages.properties -------------------------------------------------------------------------------- /src/main/resources/translations/messages_en.properties: -------------------------------------------------------------------------------- 1 | general.create_account=Create account 2 | general.login=Login 3 | general.username_email=Username (your email) 4 | general.example_username=e.g. john.smith@example.org 5 | general.sign_out=Sign out 6 | 7 | site.title=Quizzy 8 | site.footer_copy=© Example App 2018-Present 9 | 10 | login.non_confirmed_user=Your account is not confirmed yet. Please click on the link in the confirmation email. 11 | login.confirmation_link_was_already_used=The confirmation link was already used 12 | login.bad_credentials=Bad credentials 13 | login.please_login=Please login 14 | login.resend_confirmation=Resend confirmation 15 | login.password=Password 16 | login.no_account_yet=Don't have an account yet? 17 | login.create_account=Create account 18 | login.successful_sign_out=You have been signed out successfully 19 | login.your_password=your password here 20 | 21 | signup.user_already_taken=This username is already taken 22 | signup.username=Username 23 | signup.name=Name 24 | signup.first_name=First name 25 | signup.password=Password 26 | signup.passwords=Passwords 27 | signup.confirm=Confirm password 28 | signup.have_account=Have an account already? 29 | signup.example_name=e.g. John 30 | signup.password_instructions=password min 8 characters 31 | signup.confirm_instructions=type the same password 32 | signup.thank_you=Thank you for signing up 33 | signup.confirmation_message=Now you should receive the confirmation email in your inbox. Please click on the confirmation link to verify your account. 34 | 35 | confirm.invalid_confirmation_link=Invalid confirmation link 36 | confirm.code_is_invalid=The confirmation code is invalid 37 | 38 | validation.not_blank=can''t be empty 39 | validation.valid_email=must be a valid email address 40 | validation.password_min_size=must have {min} or more characters 41 | validation.passwords_match=must be the same 42 | 43 | dashboard.dashboard=Dashboard 44 | dashboard.greeting=Welcome, 45 | 46 | quiz.new_quiz=New Quiz 47 | quiz.edit_quiz=Edit Quiz 48 | quiz.title=Title 49 | quiz.title_example=e.g. Which superhero are you? 50 | quiz.image=Cover image 51 | quiz.current_image=Current cover image 52 | quiz.description=Short description 53 | quiz.description_example=e.g. Answer these 5 questions to test which superhero you are most alike! 54 | quiz.duration=Duration (in minutes) 55 | quiz.duration_placeholder=Choose duration… 56 | quiz.cta=Call to action (CTA) 57 | quiz.cta_example=e.g. Take Quiz! 58 | quiz.create=Create Quiz! 59 | quiz.update=Save changes 60 | quiz.it_will_take_you_only=It will take you only {0,,duration} minutes! 61 | quiz.edit=Edit -------------------------------------------------------------------------------- /src/test/kotlin/app/auth/AuthServiceTest.kt: -------------------------------------------------------------------------------- 1 | package app.auth 2 | 3 | import app.auth.config.ConfirmationEmailsConfig 4 | import app.auth.signup.CodeUsedAlreadyException 5 | import app.auth.signup.ConfirmationLink 6 | import app.auth.signup.ConfirmationLinkService 7 | import app.auth.signup.InvalidCodeException 8 | import app.email.EmailTemplate 9 | import app.auth.user.UserExistsAlreadyException 10 | import app.auth.user.UserNotFoundException 11 | import app.auth.user.UserRepository 12 | import app.auth.user.standardUsers 13 | import app.auth.user.standardUsers.john 14 | import app.auth.user.standardUsers.kate 15 | import com.nhaarman.mockito_kotlin.given 16 | import com.nhaarman.mockito_kotlin.mock 17 | import com.nhaarman.mockito_kotlin.verify 18 | import org.assertj.core.api.Assertions.assertThat 19 | import org.assertj.core.api.Assertions.assertThatThrownBy 20 | import org.junit.Test 21 | 22 | class AuthServiceTest { 23 | 24 | private val userRepository = mock() 25 | private val confirmationLinkService = mock() 26 | private val emailTemplate = mock() 27 | 28 | private val confirmationEmailsConfig = ConfirmationEmailsConfig().apply { 29 | from = "Alex " 30 | } 31 | 32 | private val userService = AuthService( 33 | userRepository, 34 | emailTemplate, 35 | confirmationLinkService, 36 | confirmationEmailsConfig 37 | ) 38 | 39 | private val baseUrl = "http://localhost:8080" 40 | 41 | @Test 42 | fun `signupUser - creates a user account with confirmation code`() = 43 | standardUsers.all.forEach { user -> 44 | // ARRANGE 45 | given(confirmationLinkService.generate(baseUrl)).willReturn(ConfirmationLink( 46 | href = "some-href", 47 | code = "confirmation-code" 48 | )) 49 | 50 | // ACT 51 | userService.signupUser(user, baseUrl) 52 | 53 | // ASSERT 54 | verify(userRepository).create(user.copy(confirmationCode = "confirmation-code")) 55 | } 56 | 57 | @Test 58 | fun `signupUser - sends a confirmation email`() = standardUsers.all.forEach { user -> 59 | // ARRANGE 60 | val confirmationLink = ConfirmationLink( 61 | href = "https://example.org/confirmation-link-for-${user.name}", 62 | code = "confirmation-link-for-${user.name}" 63 | ) 64 | given(confirmationLinkService.generate(baseUrl)).willReturn(confirmationLink) 65 | 66 | // ACT 67 | userService.signupUser(user, baseUrl) 68 | 69 | // ASSERT 70 | verify(emailTemplate).send( 71 | from = confirmationEmailsConfig.from, 72 | to = "${user.name} <${user.email}>", 73 | subject = "Please confirm your account", 74 | html = "emails/confirmation.html", 75 | text = "emails/confirmation.txt", 76 | context = mapOf( 77 | "name" to user.name, 78 | "confirmUrl" to confirmationLink.href 79 | ) 80 | ) 81 | } 82 | 83 | // ASSERT 84 | @Test(expected = UserExistsAlreadyException::class) 85 | fun `signupUser - fails when user already exists`() { 86 | // ARRANGE 87 | given(userRepository.findByEmail(kate.email)).willReturn(kate) 88 | 89 | // ACT 90 | userService.signupUser(kate, baseUrl) 91 | } 92 | 93 | @Test 94 | fun `getCurrentUser - finds current user by email`() = 95 | standardUsers.all.forEachIndexed { index, user -> 96 | // ARRANGE 97 | val id = 42L + index 98 | given(userRepository.findByEmail(user.email)) 99 | .willReturn(user.copy(id = id)) 100 | 101 | // ACT 102 | val currentUser = userService.getCurrentUser(user.email) 103 | 104 | // ASSERT 105 | assertThat(currentUser).isEqualTo( 106 | CurrentUser(id = id, name = user.name) 107 | ) 108 | } 109 | 110 | @Test 111 | fun `getCurrentUser - fails when user not found`() { 112 | // ARRANGE 113 | given(userRepository.findByEmail("missing@example.org")) 114 | .willReturn(null) 115 | 116 | // ACT 117 | val result = assertThatThrownBy { 118 | userService.getCurrentUser("missing@example.org") 119 | } 120 | 121 | // ASSERT 122 | result.isInstanceOf(IllegalStateException::class.java) 123 | .hasMessageContaining("Current logged in user can't be missing!") 124 | } 125 | 126 | @Test 127 | fun `getCurrentUser - fails when found user does not have an id`() { 128 | // ARRANGE 129 | given(userRepository.findByEmail("kate@example.org")) 130 | .willReturn(kate) 131 | 132 | // ACT 133 | val result = assertThatThrownBy { 134 | userService.getCurrentUser("kate@example.org") 135 | } 136 | 137 | // ASSERT 138 | result.isInstanceOf(IllegalStateException::class.java) 139 | .hasMessageContaining("Existing user has to have an id!") 140 | } 141 | 142 | @Test 143 | fun `confirmAndGetUser - confirms by valid code`() = standardUsers.all.forEach { user -> 144 | // ARRANGE 145 | val code = "valid-code" 146 | val unconfirmedUser = user.copy(confirmed = false) 147 | given(userRepository.findByConfirmationCode(code)).willReturn(unconfirmedUser) 148 | 149 | // ACT 150 | val foundUser = userService.confirmAndGetUser(code) 151 | 152 | // ASSERT 153 | val expectedConfirmedUser = user.copy(confirmed = true) 154 | assertThat(foundUser).isEqualTo(expectedConfirmedUser) 155 | verify(userRepository).confirm(expectedConfirmedUser) 156 | } 157 | 158 | @Test 159 | fun `confirmAndGetUser - confirms by other valid code`() { 160 | // ARRANGE 161 | val code = "other-valid-code" 162 | val unconfirmedUser = john.copy(confirmed = false) 163 | given(userRepository.findByConfirmationCode(code)).willReturn(unconfirmedUser) 164 | 165 | // ACT 166 | userService.confirmAndGetUser(code) 167 | 168 | // ASSERT 169 | verify(userRepository).confirm(john.copy(confirmed = true)) 170 | } 171 | 172 | // ASSERT 173 | @Test(expected = InvalidCodeException::class) 174 | fun `confirmAndGetUser - fails when code is invalid`() { 175 | // ARRANGE 176 | val code = "invalid-code" 177 | given(userRepository.findByConfirmationCode(code)).willReturn(null) 178 | 179 | // ACT 180 | userService.confirmAndGetUser(code) 181 | } 182 | 183 | // ASSERT 184 | @Test(expected = CodeUsedAlreadyException::class) 185 | fun `confirmAndGetUser - fails when code was already used`() { 186 | // ARRANGE 187 | val code = "already-used-code" 188 | given(userRepository.findByConfirmationCode(code)) 189 | .willReturn(kate.copy(confirmed = true)) 190 | 191 | // ACT 192 | userService.confirmAndGetUser(code) 193 | } 194 | 195 | @Test 196 | fun `resendConfirmation - re-sends confirmation email`() = standardUsers.all.forEach { user -> 197 | // ARRANGE 198 | val confirmationLink = ConfirmationLink( 199 | href = "https://example.org/confirmation-link-for-${user.name}", 200 | code = "confirmation-link-for-${user.name}" 201 | ) 202 | given(confirmationLinkService.generate(baseUrl)).willReturn(confirmationLink) 203 | given(userRepository.findByEmail(user.email)).willReturn(user) 204 | 205 | // ACT 206 | userService.resendConfirmation(user.email, baseUrl) 207 | 208 | // ASSERT 209 | verify(emailTemplate).send( 210 | from = confirmationEmailsConfig.from, 211 | to = "${user.name} <${user.email}>", 212 | subject = "Please confirm your account", 213 | html = "emails/confirmation.html", 214 | text = "emails/confirmation.txt", 215 | context = mapOf( 216 | "name" to user.name, 217 | "confirmUrl" to confirmationLink.href 218 | ) 219 | ) 220 | } 221 | 222 | @Test 223 | fun `resendConfirmation - updates user confirmation code`() = 224 | standardUsers.all.forEach { user -> 225 | // ARRANGE 226 | given(confirmationLinkService.generate(baseUrl)).willReturn(ConfirmationLink( 227 | href = "some-href", 228 | code = "new-confirmation-code" 229 | )) 230 | given(userRepository.findByEmail(user.email)).willReturn(user) 231 | 232 | // ACT 233 | userService.resendConfirmation(user.email, baseUrl) 234 | 235 | // ASSERT 236 | verify(userRepository).updateConfirmationCode( 237 | user.copy(confirmationCode = "new-confirmation-code") 238 | ) 239 | } 240 | 241 | // ASSERT 242 | @Test(expected = UserNotFoundException::class) 243 | fun `resendConfirmation - fails when user not found`() { 244 | // ARRANGE 245 | val email = "missing@example.org" 246 | given(confirmationLinkService.generate(baseUrl)).willReturn(ConfirmationLink( 247 | href = "some-href", 248 | code = "new-confirmation-code" 249 | )) 250 | given(userRepository.findByEmail(email)).willReturn(null) 251 | 252 | // ACT 253 | userService.resendConfirmation(email, baseUrl) 254 | } 255 | 256 | } -------------------------------------------------------------------------------- /src/test/kotlin/app/auth/dashboard/DashboardControllerTest.kt: -------------------------------------------------------------------------------- 1 | package app.auth.dashboard 2 | 3 | import app.auth.AuthService 4 | import app.auth.CurrentUser 5 | import com.nhaarman.mockito_kotlin.given 6 | import com.nhaarman.mockito_kotlin.mock 7 | import helpers.MockMvcTest 8 | import org.hamcrest.Matchers.equalTo 9 | import org.hamcrest.Matchers.hasProperty 10 | import org.junit.Test 11 | import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get 12 | import org.springframework.test.web.servlet.result.MockMvcResultMatchers.model 13 | import java.security.Principal 14 | 15 | class DashboardControllerTest : MockMvcTest { 16 | 17 | private val principal = mock() 18 | private val authService = mock() 19 | 20 | override fun controller() = DashboardController(authService) 21 | 22 | @Test 23 | fun `dashboard - renders current user name`() { 24 | // ARRANGE 25 | given(principal.name).willReturn("kate@example.org") 26 | given(authService.getCurrentUser(email = "kate@example.org")) 27 | .willReturn(CurrentUser(id = 42, name = "Kate")) 28 | 29 | // ACT 30 | val result = mockMvc().perform(get("/dashboard").principal(principal)) 31 | 32 | // ASSERT 33 | result.andExpect(model().attribute("currentUser", 34 | hasProperty("name", equalTo("Kate")))) 35 | } 36 | 37 | @Test 38 | fun `dashboard - renders current user name for different user`() { 39 | // ARRANGE 40 | given(principal.name).willReturn("john@example.org") 41 | given(authService.getCurrentUser(email = "john@example.org")) 42 | .willReturn(CurrentUser(id = 54, name = "John")) 43 | 44 | // ACT 45 | val result = mockMvc().perform(get("/dashboard").principal(principal)) 46 | 47 | // ASSERT 48 | result.andExpect(model().attribute("currentUser", 49 | hasProperty("name", equalTo("John")))) 50 | } 51 | } 52 | 53 | -------------------------------------------------------------------------------- /src/test/kotlin/app/auth/login/ForceLoginController.kt: -------------------------------------------------------------------------------- 1 | package app.auth.login 2 | 3 | import app.auth.signup.ForceLoginService 4 | import org.springframework.stereotype.Controller 5 | import org.springframework.web.bind.annotation.GetMapping 6 | import org.springframework.web.bind.annotation.RequestParam 7 | 8 | // This controller exists only for tests purposes and is not running in production 9 | @Controller 10 | class ForceLoginController(private val forceLoginService: ForceLoginService) { 11 | 12 | @GetMapping("/login/force", params = ["username"]) 13 | fun forceLogin(@RequestParam username: String): String { 14 | forceLoginService.loginUserAfterConfirmation(username) 15 | return "redirect:/dashboard" 16 | } 17 | 18 | } -------------------------------------------------------------------------------- /src/test/kotlin/app/auth/login/LoginControllerTest.kt: -------------------------------------------------------------------------------- 1 | package app.auth.login 2 | 3 | import helpers.MockMvcTest 4 | import org.junit.Test 5 | import org.springframework.security.authentication.BadCredentialsException 6 | import org.springframework.security.authentication.DisabledException 7 | import org.springframework.security.web.WebAttributes 8 | import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get 9 | import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post 10 | import org.springframework.test.web.servlet.result.MockMvcResultMatchers.model 11 | import org.springframework.test.web.servlet.result.MockMvcResultMatchers.view 12 | 13 | class LoginControllerTest : MockMvcTest { 14 | 15 | override fun controller() = LoginController() 16 | 17 | @Test 18 | fun `loginFailure - renders login page with failure when bad credentials`() { 19 | // ACT 20 | val result = mockMvc().perform(post("/login") 21 | .param("error", "") 22 | .requestAttr(WebAttributes.AUTHENTICATION_EXCEPTION, BadCredentialsException(""))) 23 | 24 | // ASSERT 25 | result.andExpect(view().name("auth/login.html")) 26 | .andExpect(model().attribute("error", "login.bad_credentials")) 27 | .andExpect(model().attribute("nonConfirmedUser", false)) 28 | } 29 | 30 | @Test 31 | fun `loginFailure - renders login page with failure when non-confirmed user logs in`() { 32 | // ACT 33 | val result = mockMvc().perform(post("/login") 34 | .param("error", "") 35 | .requestAttr(WebAttributes.AUTHENTICATION_EXCEPTION, DisabledException(""))) 36 | 37 | // ASSERT 38 | result.andExpect(view().name("auth/login.html")) 39 | .andExpect(model().attribute("error", "login.non_confirmed_user")) 40 | .andExpect(model().attribute("nonConfirmedUser", true)) 41 | } 42 | 43 | @Test 44 | fun `loginFailure - renders login page with failure when confirmation link was already used`() { 45 | // ACT 46 | val result = mockMvc().perform(get("/login") 47 | .param("error", "confirmation_link_was_already_used")) 48 | 49 | // ASSERT 50 | result.andExpect(view().name("auth/login.html")) 51 | .andExpect(model().attribute("error", "login.confirmation_link_was_already_used")) 52 | .andExpect(model().attribute("nonConfirmedUser", false)) 53 | } 54 | 55 | @Test 56 | fun `signOutSuccess - renders login page with a message`() { 57 | // ACT 58 | val result = mockMvc().perform(get("/login") 59 | .param("logout", "")) 60 | 61 | // ASSERT 62 | result.andExpect(view().name("auth/login.html")) 63 | .andExpect(model().attribute("signOutInfo", "login.successful_sign_out")) 64 | .andExpect(model().attribute("nonConfirmedUser", false)) 65 | } 66 | } -------------------------------------------------------------------------------- /src/test/kotlin/app/auth/signup/ConfirmControllerTest.kt: -------------------------------------------------------------------------------- 1 | package app.auth.signup 2 | 3 | import app.auth.AuthService 4 | import app.auth.SecurityContextWrapper 5 | import app.auth.user.UserNotFoundException 6 | import app.auth.user.asSpringSecurityToken 7 | import app.auth.user.asSpringSecurityUser 8 | import app.auth.user.standardUsers 9 | import app.auth.user.standardUsers.kate 10 | import com.nhaarman.mockito_kotlin.doReturn 11 | import com.nhaarman.mockito_kotlin.given 12 | import com.nhaarman.mockito_kotlin.mock 13 | import com.nhaarman.mockito_kotlin.verify 14 | import helpers.MockMvcTest 15 | import org.junit.Test 16 | import org.springframework.security.core.authority.SimpleGrantedAuthority 17 | import org.springframework.security.core.userdetails.UserDetailsService 18 | import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get 19 | import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post 20 | import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* 21 | 22 | class ConfirmControllerTest : MockMvcTest { 23 | 24 | private val userService = mock() 25 | 26 | private val userDetailsService = mock { 27 | standardUsers.all.forEach { user -> 28 | on { loadUserByUsername(user.email) } doReturn user.asSpringSecurityUser() 29 | } 30 | } 31 | 32 | private val securityContextWrapper = mock() 33 | private val forceLoginService = ForceLoginService(userDetailsService, securityContextWrapper) 34 | 35 | override fun controller() = ConfirmController(userService, forceLoginService) 36 | 37 | @Test 38 | fun `confirm - confirms the account`() = 39 | listOf("code-1", "code-2").forEach { code -> 40 | // ARRANGE 41 | given(userService.confirmAndGetUser(code)).willReturn(kate) 42 | 43 | // ACT 44 | mockMvc().perform(get("/confirm/$code")) 45 | 46 | // ASSERT 47 | verify(userService).confirmAndGetUser(code) 48 | } 49 | 50 | @Test 51 | fun `confirm - logs in after confirming`() = standardUsers.all.forEach { user -> 52 | // ARRANGE 53 | val code = "valid-code" 54 | given(userService.confirmAndGetUser(code)).willReturn(user) 55 | 56 | // ACT 57 | mockMvc().perform(get("/confirm/$code")) 58 | 59 | // ASSERT 60 | verify(securityContextWrapper).authentication = user.asSpringSecurityToken() 61 | } 62 | 63 | @Test 64 | fun `confirm - logs in after confirming with different granted authorities`() { 65 | // ARRANGE 66 | val code = "valid-code" 67 | given(userService.confirmAndGetUser(code)).willReturn(kate) 68 | 69 | val otherAuthorities = listOf(SimpleGrantedAuthority("ROLE_OTHER")) 70 | given(userDetailsService.loadUserByUsername(kate.email)).willReturn( 71 | kate.asSpringSecurityUser(authorities = otherAuthorities)) 72 | 73 | // ACT 74 | mockMvc().perform(get("/confirm/$code")) 75 | 76 | // ASSERT 77 | verify(securityContextWrapper).authentication = 78 | kate.asSpringSecurityToken(authorities = otherAuthorities) 79 | } 80 | 81 | @Test 82 | fun `confirm - redirects to dashboard after login`() { 83 | // ARRANGE 84 | val code = "good-code" 85 | given(userService.confirmAndGetUser(code)).willReturn(kate) 86 | 87 | // ACT 88 | val result = mockMvc().perform(get("/confirm/$code")) 89 | 90 | // ASSERT 91 | result.andExpect(status().isFound) 92 | .andExpect(redirectedUrl("/dashboard")) 93 | } 94 | 95 | @Test 96 | fun `confirm - informs user about invalid confirmation link`() { 97 | // ARRANGE 98 | val code = "invalid-code" 99 | given(userService.confirmAndGetUser(code)).willThrow(InvalidCodeException()) 100 | 101 | // ACT 102 | val result = mockMvc().perform(get("/confirm/$code")) 103 | 104 | // ASSERT 105 | result.andExpect(view().name("auth/invalid_confirmation_link.html")) 106 | } 107 | 108 | @Test 109 | fun `confirm - redirects to login when confirmation link was already used`() { 110 | // ARRANGE 111 | val code = "already-used-code" 112 | given(userService.confirmAndGetUser(code)).willThrow(CodeUsedAlreadyException()) 113 | 114 | // ACT 115 | val result = mockMvc().perform(get("/confirm/$code")) 116 | 117 | // ASSERT 118 | result.andExpect(status().isFound) 119 | .andExpect(redirectedUrl("/login?error=confirmation_link_was_already_used")) 120 | } 121 | 122 | @Test 123 | fun `resendConfirmation - re-sends confirmation and redirects to thank you page`() = 124 | standardUsers.all.forEach { user -> 125 | // ACT 126 | val result = mockMvc().perform(post("/some/context/path/resend-confirmation") 127 | .contextPath("/some/context/path") 128 | .param("username", user.email)) 129 | 130 | // ASSERT 131 | result.andExpect(status().isFound) 132 | .andExpect(redirectedUrl("/some/context/path/thank-you")) 133 | verify(userService).resendConfirmation( 134 | user.email, 135 | "http://localhost/some/context/path" 136 | ) 137 | } 138 | 139 | @Test 140 | fun `resendConfirmation - is not found when user not found`() { 141 | // ARRANGE 142 | val email = "missing@example.org" 143 | given(userService.resendConfirmation(email, "http://localhost")) 144 | .willThrow(UserNotFoundException::class.java) 145 | 146 | // ACT 147 | val result = mockMvc().perform(post("/resend-confirmation") 148 | .param("username", email)) 149 | 150 | // ASSERT 151 | result.andExpect(status().isNotFound) 152 | } 153 | 154 | } -------------------------------------------------------------------------------- /src/test/kotlin/app/auth/signup/ConfirmationLinkServiceTest.kt: -------------------------------------------------------------------------------- 1 | package app.auth.signup 2 | 3 | import org.assertj.core.api.Assertions.assertThat 4 | import org.junit.Test 5 | 6 | class ConfirmationLinkServiceTest { 7 | 8 | private val service = ConfirmationLinkService() 9 | 10 | @Test 11 | fun `generate - generates unique links`() { 12 | // ARRANGE 13 | val baseUrl = "http://localhost" 14 | 15 | // ACT 16 | val one = service.generate(baseUrl) 17 | val two = service.generate(baseUrl) 18 | 19 | // ASSERT 20 | assertThat(one.code).isNotEqualTo(two.code) 21 | assertThat(one.href).contains(one.code) 22 | assertThat(two.href).contains(two.code) 23 | } 24 | 25 | // ARRANGE 26 | private val baseUrls = listOf("http://localhost:8080", "https://example.org/context") 27 | 28 | @Test 29 | fun `generate - generates link prefixed with confirm path`() = baseUrls.forEach { baseUrl -> 30 | // ACT 31 | val link = service.generate(baseUrl) 32 | 33 | // ASSERT 34 | assertThat(link.href).startsWith("$baseUrl/confirm/") 35 | } 36 | } -------------------------------------------------------------------------------- /src/test/kotlin/app/auth/signup/SignupControllerTest.kt: -------------------------------------------------------------------------------- 1 | package app.auth.signup 2 | 3 | import app.auth.AuthService 4 | import app.auth.user.User 5 | import app.auth.user.UserExistsAlreadyException 6 | import app.auth.user.standardUsers 7 | import app.auth.user.standardUsers.kate 8 | import app.util.TimeProvider 9 | import com.nhaarman.mockito_kotlin.doReturn 10 | import com.nhaarman.mockito_kotlin.given 11 | import com.nhaarman.mockito_kotlin.mock 12 | import com.nhaarman.mockito_kotlin.verify 13 | import helpers.MockMvcTest 14 | import helpers.today 15 | import org.junit.Test 16 | import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post 17 | import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* 18 | import org.springframework.util.LinkedMultiValueMap 19 | 20 | class SignupControllerTest : MockMvcTest { 21 | 22 | private val userService = mock() 23 | 24 | private val timeProvider = mock { 25 | on { now() } doReturn today 26 | } 27 | 28 | override fun controller() = SignupController(userService, timeProvider) 29 | 30 | @Test 31 | fun `signup - redirects to thank you page`() { 32 | // ACT 33 | val result = mockMvc().perform(post("/signup") 34 | .params(signupRequestFor(kate))) 35 | 36 | // ASSERT 37 | result.andExpect(status().isFound) 38 | .andExpect(redirectedUrl("/thank-you")) 39 | } 40 | 41 | @Test 42 | fun `signup - creates an account`() { 43 | standardUsers.all.forEach { user -> 44 | // ACT 45 | mockMvc().perform(post("/signup") 46 | .params(signupRequestFor(user))) 47 | 48 | // ASSERT 49 | val expectedUser = expectedCreatedUserFor(user) 50 | verify(userService).signupUser(expectedUser, "http://localhost") 51 | } 52 | } 53 | 54 | @Test 55 | fun `signup - is aware of context path when getting the base url`() { 56 | // ACT 57 | mockMvc().perform(post("/some/context/path/signup") 58 | .contextPath("/some/context/path") 59 | .params(signupRequestFor(kate))) 60 | 61 | // ASSERT 62 | val expectedUser = expectedCreatedUserFor(kate) 63 | verify(userService).signupUser(expectedUser, "http://localhost/some/context/path") 64 | } 65 | 66 | @Test 67 | fun `signup - fails when user already exists`() { 68 | // ARRANGE 69 | val baseUrl = "http://localhost" 70 | val expectedUser = expectedCreatedUserFor(kate) 71 | given(userService.signupUser(expectedUser, baseUrl)) 72 | .willThrow(UserExistsAlreadyException()) 73 | 74 | // ACT 75 | val result = mockMvc().perform(post("/signup") 76 | .params(signupRequestFor(kate))) 77 | 78 | // ASSERT 79 | result.andExpect(view().name("auth/signup.html")) 80 | .andExpect(model().attributeHasNoErrors("form")) 81 | .andExpect(model().attribute("error", "signup.user_already_taken")) 82 | } 83 | 84 | @Test 85 | fun `signup - renders validation failure when email is invalid`() { 86 | // ACT 87 | val result = mockMvc().perform(post("/signup") 88 | .params(signupRequestFor(User( 89 | email = "invalid", 90 | name = "irrelevant", 91 | password = "irrelevant", 92 | createdAt = today, 93 | updatedAt = today 94 | )))) 95 | 96 | // ASSERT 97 | result.andExpect(view().name("auth/signup.html")) 98 | .andExpect(model().attributeHasFieldErrors("form", "username")) 99 | } 100 | 101 | @Test 102 | fun `signup - renders validation failure when email is empty`() { 103 | // ACT 104 | val result = mockMvc().perform(post("/signup") 105 | .params(signupRequestFor(User( 106 | email = "", 107 | name = "irrelevant", 108 | password = "irrelevant", 109 | createdAt = today, 110 | updatedAt = today 111 | )))) 112 | 113 | // ASSERT 114 | result.andExpect(view().name("auth/signup.html")) 115 | .andExpect(model().attributeHasFieldErrors("form", "username")) 116 | } 117 | 118 | @Test 119 | fun `signup - renders validation failure when name is empty`() { 120 | // ACT 121 | val result = mockMvc().perform(post("/signup") 122 | .params(signupRequestFor(User( 123 | email = "irrelevant@example.org", 124 | name = "", 125 | password = "irrelevant", 126 | createdAt = today, 127 | updatedAt = today 128 | )))) 129 | 130 | // ASSERT 131 | result.andExpect(view().name("auth/signup.html")) 132 | .andExpect(model().attributeHasFieldErrors("form", "name")) 133 | } 134 | 135 | @Test 136 | fun `signup - renders validation failure when password is too short`() { 137 | // ACT 138 | val result = mockMvc().perform(post("/signup") 139 | .params(signupRequestFor(User( 140 | email = "irrelevant@example.org", 141 | name = "irrelevant", 142 | password = "short", 143 | createdAt = today, 144 | updatedAt = today 145 | )))) 146 | 147 | // ASSERT 148 | result.andExpect(view().name("auth/signup.html")) 149 | .andExpect(model().attributeHasFieldErrors("form", "pass")) 150 | } 151 | 152 | @Test 153 | fun `signup - renders validation failure when password do not match`() { 154 | // ACT 155 | val result = mockMvc().perform(post("/signup") 156 | .param("username", "irrelevant@example.org") 157 | .param("name", "irrelevant") 158 | .param("pass", "goodpassword") 159 | .param("confirm", "doesnotmatch")) 160 | 161 | // ASSERT 162 | result.andExpect(view().name("auth/signup.html")) 163 | .andExpect(model().attributeHasFieldErrors("form", "pass")) 164 | .andExpect(model().attributeHasFieldErrors("form", "confirm")) 165 | } 166 | 167 | private fun signupRequestFor(user: User) = 168 | LinkedMultiValueMap().apply { 169 | add("username", user.email) 170 | add("name", user.name) 171 | add("pass", user.password) 172 | add("confirm", user.password) 173 | } 174 | 175 | private fun expectedCreatedUserFor(user: User) = 176 | User( 177 | email = user.email, 178 | password = user.password, 179 | name = user.name, 180 | createdAt = today, 181 | updatedAt = today 182 | ) 183 | } -------------------------------------------------------------------------------- /src/test/kotlin/app/auth/user/UserRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | package app.auth.user 2 | 3 | import app.auth.user.standardUsers.john 4 | import app.auth.user.standardUsers.kate 5 | import app.auth.user.standardUsers.nonConfirmedUser 6 | import helpers.RepositoryTest 7 | import helpers.resetSerial 8 | import org.assertj.core.api.Assertions.assertThat 9 | import org.junit.Before 10 | import org.junit.Test 11 | import org.springframework.dao.DuplicateKeyException 12 | 13 | class UserRepositoryTest : RepositoryTest() { 14 | 15 | private lateinit var userRepository: UserRepository 16 | 17 | @Before 18 | fun `before each`() { 19 | jdbcTemplate.update("delete from quizzes cascade") 20 | jdbcTemplate.update("delete from users cascade") 21 | jdbcTemplate.resetSerial() 22 | 23 | userRepository = UserRepository(jdbcTemplate, passwordEncoder) 24 | } 25 | 26 | @Test 27 | fun `findAll - returns empty when there are no users`() { 28 | // ACT 29 | val users = userRepository.findAll() 30 | 31 | // ASSERT 32 | assertThat(users).isEmpty() 33 | } 34 | 35 | @Test 36 | fun `create - creates a user and findAll finds it`() { 37 | // ACT 38 | userRepository.create(kate) 39 | 40 | // ASSERT 41 | val users = userRepository.findAll() 42 | assertThat(users).isEqualTo(listOf( 43 | kate.copy(id = 1).withEncodedPassword() 44 | )) 45 | } 46 | 47 | @Test 48 | fun `create - creates multiple users`() { 49 | // ACT 50 | userRepository.create(john) 51 | userRepository.create(kate) 52 | 53 | // ASSERT 54 | val users = userRepository.findAll().sortedBy { it.id } 55 | assertThat(users).isEqualTo(listOf( 56 | john.copy(id = 1).withEncodedPassword(), 57 | kate.copy(id = 2).withEncodedPassword() 58 | )) 59 | } 60 | 61 | // ASSERT 62 | @Test(expected = DuplicateKeyException::class) 63 | fun `create - no duplicate emails`() { 64 | // ARRANGE 65 | userRepository.create(john.copy(email = "duplicate@example.org")) 66 | 67 | // ACT 68 | userRepository.create(kate.copy(email = "duplicate@example.org")) 69 | } 70 | 71 | // ASSERT 72 | @Test(expected = DuplicateKeyException::class) 73 | fun `create - no duplicate confirmation codes`() { 74 | // ARRANGE 75 | userRepository.create(john.copy(confirmationCode = "duplicate")) 76 | 77 | // ACT 78 | userRepository.create(kate.copy(confirmationCode = "duplicate")) 79 | } 80 | 81 | @Test 82 | fun `confirm - updates confirmed to true`() { 83 | // ARRANGE 84 | userRepository.create(john.copy(confirmed = false)) 85 | userRepository.create(kate.copy(confirmed = false)) 86 | 87 | // ACT 88 | userRepository.confirm(john.copy(id = 1)) 89 | 90 | // ASSERT 91 | val users = userRepository.findAll().sortedBy { it.id } 92 | assertThat(users).isEqualTo(listOf( 93 | john.copy(id = 1, confirmed = true).withEncodedPassword(), 94 | kate.copy(id = 2, confirmed = false).withEncodedPassword() 95 | )) 96 | } 97 | 98 | @Test 99 | fun `confirm - updates other user's confirmed to true`() { 100 | // ARRANGE 101 | userRepository.create(john.copy(confirmed = false)) 102 | userRepository.create(kate.copy(confirmed = false)) 103 | 104 | // ACT 105 | userRepository.confirm(kate.copy(id = 2)) 106 | 107 | // ASSERT 108 | val users = userRepository.findAll().sortedBy { it.id } 109 | assertThat(users).isEqualTo(listOf( 110 | john.copy(id = 1, confirmed = false).withEncodedPassword(), 111 | kate.copy(id = 2, confirmed = true).withEncodedPassword() 112 | )) 113 | } 114 | 115 | @Test 116 | fun `updateConfirmationCode - updates confirmation code`() { 117 | // ARRANGE 118 | userRepository.create(john.copy(confirmationCode = "john-old-code")) 119 | userRepository.create(kate.copy(confirmationCode = "kate-old-code")) 120 | 121 | listOf("new-code", "other-code").forEach { newCode -> 122 | // ACT 123 | userRepository.updateConfirmationCode(john.copy(id = 1, confirmationCode = newCode)) 124 | 125 | // ASSERT 126 | val users = userRepository.findAll().sortedBy { it.id } 127 | assertThat(users).isEqualTo(listOf( 128 | john.copy(id = 1, confirmationCode = newCode).withEncodedPassword(), 129 | kate.copy(id = 2, confirmationCode = "kate-old-code").withEncodedPassword() 130 | )) 131 | } 132 | } 133 | 134 | @Test 135 | fun `updateConfirmationCode - updates confirmation code for other user`() { 136 | // ARRANGE 137 | userRepository.create(john.copy(confirmationCode = "john-old-code")) 138 | userRepository.create(kate.copy(confirmationCode = "kate-old-code")) 139 | val newCode = "new-code" 140 | 141 | // ACT 142 | userRepository.updateConfirmationCode(kate.copy(id = 2, confirmationCode = newCode)) 143 | 144 | // ASSERT 145 | val users = userRepository.findAll().sortedBy { it.id } 146 | assertThat(users).isEqualTo(listOf( 147 | john.copy(id = 1, confirmationCode = "john-old-code").withEncodedPassword(), 148 | kate.copy(id = 2, confirmationCode = newCode).withEncodedPassword() 149 | )) 150 | } 151 | 152 | 153 | @Test 154 | fun `findByConfirmationCode - finds by confirmation code`() { 155 | // ARRANGE 156 | userRepository.create(john) 157 | userRepository.create(kate) 158 | 159 | // ACT & ASSERT 160 | assertThat(userRepository.findByConfirmationCode(john.confirmationCode!!)) 161 | .isEqualTo(john.copy(id = 1).withEncodedPassword()) 162 | 163 | assertThat(userRepository.findByConfirmationCode(kate.confirmationCode!!)) 164 | .isEqualTo(kate.copy(id = 2).withEncodedPassword()) 165 | } 166 | 167 | @Test 168 | fun `findByConfirmationCode - returns null when code is invalid`() { 169 | // ARRANGE 170 | val code = "invalid-code" 171 | 172 | // ACT 173 | val actual = userRepository.findByConfirmationCode(code) 174 | 175 | // ASSERT 176 | assertThat(actual).isNull() 177 | } 178 | 179 | @Test 180 | fun `findByEmail - finds user by email`() { 181 | // ARRANGE 182 | userRepository.create(john) 183 | userRepository.create(kate) 184 | 185 | // ACT 186 | val user = userRepository.findByEmail(john.email) 187 | 188 | // ASSERT 189 | assertThat(user).isEqualTo(john.copy(id = 1).withEncodedPassword()) 190 | } 191 | 192 | @Test 193 | fun `findByEmail - finds user by different email`() { 194 | // ARRANGE 195 | userRepository.create(john) 196 | userRepository.create(kate) 197 | 198 | // ACT 199 | val user = userRepository.findByEmail(kate.email) 200 | 201 | // ASSERT 202 | assertThat(user).isEqualTo(kate.copy(id = 2).withEncodedPassword()) 203 | } 204 | 205 | @Test 206 | fun `findByEmail - returns null when can't find the user`() { 207 | // ARRANGE 208 | userRepository.create(john) 209 | userRepository.create(kate) 210 | 211 | // ACT 212 | val user = userRepository.findByEmail("missing@example.org") 213 | 214 | // ASSERT 215 | assertThat(user).isNull() 216 | } 217 | 218 | @Test 219 | fun `usersByUsernameQuery - finds single user by username - for spring security integration`() { 220 | // ARRANGE 221 | userRepository.create(john) 222 | userRepository.create(kate) 223 | userRepository.create(nonConfirmedUser) 224 | 225 | standardUsers.allIncludingNonConfirmed.forEach { user -> 226 | // ACT 227 | val users = jdbcTemplate.queryForList(UserRepository.usersByUsernameQuery, user.email) 228 | 229 | // ASSERT 230 | assertThat(users).hasSize(1) 231 | assertThat(users[0]).isEqualTo(mapOf( 232 | "username" to user.email, 233 | "password" to user.password.encoded(), 234 | "enabled" to user.confirmed 235 | )) 236 | } 237 | } 238 | 239 | @Test 240 | fun `authoritiesByUsernameQuery - always returns 'user' authority for now - for spring security integration`() { 241 | // ARRANGE 242 | userRepository.create(john) 243 | userRepository.create(kate) 244 | 245 | standardUsers.all.forEach { 246 | // ACT 247 | val authorities = jdbcTemplate.queryForList(UserRepository.authoritiesByUsernameQuery, it.email) 248 | 249 | // ASSERT 250 | assertThat(authorities).hasSize(1) 251 | assertThat(authorities[0]["username"]).isEqualTo(it.email) 252 | assertThat(authorities[0]["role"]).isEqualTo("user") 253 | } 254 | } 255 | 256 | @Test 257 | fun `passwordEncoder's encode - encodes password simplistically`() { 258 | listOf("password", "different").forEach { 259 | // ACT 260 | val encoded = passwordEncoder.encode(it) 261 | 262 | // ASSERT 263 | assertThat(encoded).isEqualTo("$it") 264 | } 265 | } 266 | 267 | private fun User.withEncodedPassword() = copy( 268 | password = password.encoded(), 269 | isPasswordEncoded = true 270 | ) 271 | 272 | private fun String.encoded() = 273 | passwordEncoder.encode(this) 274 | 275 | } 276 | -------------------------------------------------------------------------------- /src/test/kotlin/app/auth/user/createUserFactory.kt: -------------------------------------------------------------------------------- 1 | package app.auth.user 2 | 3 | import helpers.today 4 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken 5 | import org.springframework.security.core.GrantedAuthority 6 | import org.springframework.security.core.authority.SimpleGrantedAuthority 7 | import java.time.LocalDateTime 8 | 9 | internal fun User.Companion.create(name: String = "irrelevant", 10 | password: String = "irrelevant", 11 | email: String = "irrelevant", 12 | confirmed: Boolean = true, 13 | confirmationCode: String = "irrelevant", 14 | createdAt: LocalDateTime = today, 15 | updatedAt: LocalDateTime = today) = 16 | User( 17 | name = name, 18 | password = password, 19 | email = email, 20 | confirmed = confirmed, 21 | confirmationCode = confirmationCode, 22 | createdAt = createdAt, 23 | updatedAt = updatedAt 24 | ) 25 | 26 | internal val defaultAuthorities = listOf(SimpleGrantedAuthority("ROLE_USER")) 27 | 28 | internal fun User.asSpringSecurityUser(authorities: List = defaultAuthorities) = 29 | org.springframework.security.core.userdetails.User( 30 | email, password, authorities 31 | ) 32 | 33 | internal fun User.asSpringSecurityToken(authorities: List = defaultAuthorities) = 34 | UsernamePasswordAuthenticationToken( 35 | email, password, authorities 36 | ) 37 | 38 | @Suppress("ClassName") 39 | internal object standardUsers { 40 | val kate = User.create( 41 | name = "Kate", 42 | email = "kate@example.org", 43 | password = "katewelcome", 44 | confirmationCode = "kate-confirmation-code" 45 | ) 46 | 47 | val john = User.create( 48 | name = "John", 49 | email = "john@example.org", 50 | password = "welcomejohn", 51 | confirmationCode = "john-confirmation-code" 52 | ) 53 | 54 | val nonConfirmedUser = User.create( 55 | confirmed = false 56 | ) 57 | 58 | val all = listOf(kate, john) 59 | val allIncludingNonConfirmed = listOf(kate, john, nonConfirmedUser) 60 | } 61 | -------------------------------------------------------------------------------- /src/test/kotlin/app/auth/user/passwordEncoder.kt: -------------------------------------------------------------------------------- 1 | package app.auth.user 2 | 3 | import com.nhaarman.mockito_kotlin.doAnswer 4 | import com.nhaarman.mockito_kotlin.mock 5 | import helpers.component1 6 | import org.mockito.ArgumentMatchers 7 | import org.springframework.security.crypto.password.PasswordEncoder 8 | 9 | val passwordEncoder = mock { 10 | on { encode(ArgumentMatchers.anyString()) } doAnswer { (rawPassword) -> 11 | "$rawPassword" 12 | } 13 | } -------------------------------------------------------------------------------- /src/test/kotlin/app/email/EmailTemplateTest.kt: -------------------------------------------------------------------------------- 1 | package app.email 2 | 3 | import com.nhaarman.mockito_kotlin.verify 4 | import helpers.EmailTest 5 | import org.junit.Test 6 | 7 | class EmailTemplateTest : EmailTest() { 8 | 9 | @Test 10 | fun `render - creates an email message from template and model`() { 11 | emailTemplate.send( 12 | from = "noreply@example.org", 13 | to = "john@example.org", 14 | subject = "Please confirm your account", 15 | html = "test_emails/testEmail.html", 16 | text = "test_emails/testEmail.txt", 17 | context = mapOf( 18 | "name" to "John", 19 | "confirmUrl" to "https://example.org/confirm/valid-code" 20 | ) 21 | ) 22 | 23 | verify(emailService).send(EmailMessage( 24 | from = "noreply@example.org", 25 | to = "john@example.org", 26 | subject = "Please confirm your account", 27 | htmlBody = """ 28 | 29 | 30 | 31 |

Hey, John!

32 | 33 |

Please click on the following link:

34 | 35 |

Confirm my account

36 | 37 | 38 | """.trimIndent(), 39 | textBody = """ 40 | Hey, John! 41 | 42 | Please copy and paste the following link into your browser: 43 | 44 | https://example.org/confirm/valid-code 45 | """.trimIndent() 46 | )) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/test/kotlin/app/email/RealEmailServiceTest.kt: -------------------------------------------------------------------------------- 1 | package app.email 2 | 3 | import com.nhaarman.mockito_kotlin.any 4 | import com.nhaarman.mockito_kotlin.doAnswer 5 | import com.nhaarman.mockito_kotlin.doReturn 6 | import com.nhaarman.mockito_kotlin.mock 7 | import helpers.component1 8 | import org.assertj.core.api.Assertions.assertThat 9 | import org.junit.Test 10 | import org.springframework.mail.javamail.JavaMailSender 11 | import java.io.ByteArrayOutputStream 12 | import javax.mail.internet.InternetAddress 13 | import javax.mail.internet.MimeMessage 14 | import javax.mail.internet.MimeMultipart 15 | 16 | class RealEmailServiceTest { 17 | private var lastMessageSent: MimeMessage? = null 18 | private val savingMailSender = mock { 19 | val stubMessage = MimeMessage(mock()) 20 | 21 | on { send(any()) } doAnswer { (message) -> 22 | lastMessageSent = message as? MimeMessage 23 | Unit 24 | } 25 | 26 | on { createMimeMessage() } doReturn stubMessage 27 | } 28 | 29 | private val emailService = RealEmailService(savingMailSender) 30 | 31 | @Test 32 | fun `send - sends email with text only`() { 33 | val message = EmailMessage( 34 | from = "hello@example.org", 35 | to = "kate@example.org", 36 | subject = "Hi from Example Org", 37 | htmlBody = null, 38 | textBody = "O, hi there, Kate!" 39 | ) 40 | 41 | emailService.send(message) 42 | 43 | assertThat(lastMessageSent?.from).isEqualTo(arrayOf(InternetAddress(message.from))) 44 | assertThat(lastMessageSent?.allRecipients).isEqualTo(arrayOf(InternetAddress(message.to))) 45 | assertThat(lastMessageSent?.subject).isEqualTo(message.subject) 46 | assertThat(lastMessageSent?.content).isEqualTo(message.textBody) 47 | } 48 | 49 | @Test 50 | fun `send - sends email with text and html`() { 51 | val message = EmailMessage( 52 | from = "hello@example.org", 53 | to = "kate@example.org", 54 | subject = "Hi from Example Org", 55 | htmlBody = "

O, hi there, Kate!

", 56 | textBody = "O, hi there, Kate!" 57 | ) 58 | 59 | emailService.send(message) 60 | 61 | assertThat(lastMessageSent?.from).isEqualTo(arrayOf(InternetAddress(message.from))) 62 | assertThat(lastMessageSent?.allRecipients).isEqualTo(arrayOf(InternetAddress(message.to))) 63 | assertThat(lastMessageSent?.subject).isEqualTo(message.subject) 64 | 65 | val content = lastMessageSent?.content as MimeMultipart 66 | val stream = ByteArrayOutputStream() 67 | content.writeTo(stream) 68 | val result = stream.toString("utf-8") 69 | 70 | assertThat(result).contains(message.htmlBody) 71 | assertThat(result).contains(message.textBody) 72 | } 73 | 74 | } -------------------------------------------------------------------------------- /src/test/kotlin/app/email/TestEmailService.kt: -------------------------------------------------------------------------------- 1 | package app.email 2 | 3 | import org.springframework.stereotype.Service 4 | 5 | @Service 6 | class TestEmailService : EmailService { 7 | var lastEmail: EmailMessage? = null 8 | 9 | override fun send(message: EmailMessage) { 10 | lastEmail = message 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/test/kotlin/app/quiz/QuizControllerTest.kt: -------------------------------------------------------------------------------- 1 | package app.quiz 2 | 3 | import app.auth.AuthService 4 | import app.auth.CurrentUser 5 | import app.quiz.standardQuizzes.quizOne 6 | import app.quiz.standardQuizzes.quizTwo 7 | import com.nhaarman.mockito_kotlin.doReturn 8 | import com.nhaarman.mockito_kotlin.given 9 | import com.nhaarman.mockito_kotlin.mock 10 | import com.nhaarman.mockito_kotlin.verify 11 | import helpers.MockMvcTest 12 | import org.junit.Test 13 | import org.springframework.mock.web.MockMultipartFile 14 | import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get 15 | import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart 16 | import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* 17 | import java.security.Principal 18 | 19 | class QuizControllerTest : MockMvcTest { 20 | private val quizService = mock() 21 | 22 | private val currentUser = CurrentUser(id = 29, name = "user@example.org") 23 | private val authService = mock() { 24 | on { getCurrentUser("user@example.org") } doReturn currentUser 25 | } 26 | 27 | private val principal = mock { 28 | on { name } doReturn "user@example.org" 29 | } 30 | 31 | override fun controller() = QuizController(quizService, authService) 32 | 33 | @Test 34 | fun `createQuiz - redirects to quiz list when successful`() { 35 | // ARRANGE 36 | val uploadedImage = "image here".toByteArray() 37 | 38 | // ACT 39 | val result = mockMvc().perform(multipart("/quizzes") 40 | .file("image", uploadedImage) 41 | .param("title", "Quiz title") 42 | .param("description", "Quiz description") 43 | .param("duration", "3") 44 | .param("cta", "Quiz CTA") 45 | .principal(principal)) 46 | 47 | // ASSERT 48 | result.andExpect(status().isFound) 49 | .andExpect(redirectedUrl("/quizzes")) 50 | } 51 | 52 | @Test 53 | fun `createQuiz - creates the quiz`() { 54 | // ARRANGE 55 | val uploadedImage = "image here".toByteArray() 56 | 57 | // ACT 58 | mockMvc().perform(multipart("/quizzes") 59 | .file("image", uploadedImage) 60 | .param("title", "Quiz title") 61 | .param("description", "Quiz description") 62 | .param("duration", "3") 63 | .param("cta", "Quiz CTA") 64 | .principal(principal)) 65 | 66 | // ASSERT 67 | verify(quizService).createQuiz(CreateQuizRequest( 68 | userId = 29, 69 | title = "Quiz title", 70 | image = uploadedImage, 71 | description = "Quiz description", 72 | durationInMinutes = 3, 73 | cta = "Quiz CTA" 74 | )) 75 | } 76 | 77 | @Test 78 | fun `createQuiz - validates fields`() { 79 | // ARRANGE 80 | val uploadedImage = "image here".toByteArray() 81 | 82 | // ACT 83 | val result = mockMvc().perform(multipart("/quizzes") 84 | .file("image", uploadedImage) 85 | .param("title", "") 86 | .param("description", "") 87 | .param("duration", "-1") 88 | .param("cta", "") 89 | .principal(principal)) 90 | 91 | // ASSERT 92 | result.andExpect(view().name("quizzes/new.html")) 93 | .andExpect(model().attributeHasFieldErrors("form", "title")) 94 | .andExpect(model().attributeHasFieldErrors("form", "description")) 95 | .andExpect(model().attributeHasFieldErrors("form", "duration")) 96 | .andExpect(model().attributeHasFieldErrors("form", "cta")) 97 | } 98 | 99 | @Test 100 | fun `createQuiz - duration can't be empty`() { 101 | // ACT 102 | val result = mockMvc().perform(multipart("/quizzes").principal(principal)) 103 | 104 | // ASSERT 105 | result.andExpect(view().name("quizzes/new.html")) 106 | .andExpect(model().attributeHasFieldErrors("form", "duration")) 107 | } 108 | 109 | 110 | @Test 111 | fun `createQuiz - validates image upload when not present`() { 112 | // ACT 113 | val result = mockMvc().perform(multipart("/quizzes").principal(principal)) 114 | 115 | // ASSERT 116 | result.andExpect(view().name("quizzes/new.html")) 117 | .andExpect(model().attributeHasFieldErrors("form", "image")) 118 | } 119 | 120 | @Test 121 | fun `createQuiz - validates image upload when empty`() { 122 | // ARRANGE 123 | val emptyUpload = mock { 124 | on { isEmpty } doReturn true 125 | on { name } doReturn "image" 126 | } 127 | 128 | // ACT 129 | val result = mockMvc().perform(multipart("/quizzes") 130 | .file(emptyUpload) 131 | .principal(principal)) 132 | 133 | // ASSERT 134 | result.andExpect(view().name("quizzes/new.html")) 135 | .andExpect(model().attributeHasFieldErrors("form", "image")) 136 | } 137 | 138 | @Test 139 | fun `editQuiz - finds quiz and renders its form`() { 140 | // ARRANGE 141 | val quiz = quizOne.copy(id = 42, userId = 29) 142 | given(quizService.getQuizForEditing(42, currentUser)).willReturn(quiz) 143 | 144 | // ACT 145 | val result = mockMvc().perform(get("/quizzes/42/edit").principal(principal)) 146 | 147 | // ASSERT 148 | result.andExpect(view().name("quizzes/edit.html")) 149 | .andExpect(model().attribute("form", EditQuizForm( 150 | id = quiz.id, 151 | title = quiz.title, 152 | description = quiz.description, 153 | duration = quiz.durationInMinutes, 154 | cta = quiz.cta, 155 | currentImageUrl = quiz.imageUrl 156 | ))) 157 | } 158 | 159 | @Test 160 | fun `editQuiz - returns not found when quiz is not found`() { 161 | // ARRANGE 162 | given(quizService.getQuizForEditing(42, currentUser)) 163 | .willThrow(QuizNotFoundException()) 164 | 165 | // ACT 166 | val result = mockMvc().perform(get("/quizzes/42/edit").principal(principal)) 167 | 168 | // ASSERT 169 | result.andExpect(status().isNotFound) 170 | } 171 | 172 | @Test 173 | fun `listQuizzes - lists quizzes`() { 174 | // ARRANGE 175 | val quizzes = listOf(quizOne, quizTwo) 176 | given(quizService.getMostRecentQuizzesForUser(currentUser)) 177 | .willReturn(quizzes) 178 | 179 | // ACT 180 | val result = mockMvc().perform(get("/quizzes").principal(principal)) 181 | 182 | // ASSERT 183 | result.andExpect(view().name("quizzes/list.html")) 184 | .andExpect(model().attribute("quizzes", quizzes)) 185 | } 186 | 187 | } -------------------------------------------------------------------------------- /src/test/kotlin/app/quiz/QuizRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | package app.quiz 2 | 3 | import app.quiz.standardQuizzes.quizOne 4 | import app.quiz.standardQuizzes.quizTwo 5 | import app.auth.user.UserRepository 6 | import app.auth.user.passwordEncoder 7 | import app.auth.user.standardUsers.john 8 | import app.auth.user.standardUsers.kate 9 | import helpers.RepositoryTest 10 | import helpers.resetSerial 11 | import helpers.today 12 | import helpers.yesterday 13 | import org.assertj.core.api.Assertions.assertThat 14 | import org.junit.Before 15 | import org.junit.Test 16 | 17 | class QuizRepositoryTest : RepositoryTest() { 18 | 19 | private lateinit var userRepository: UserRepository 20 | private lateinit var quizRepository: QuizRepository 21 | 22 | private val kateId = 1L 23 | private val johnId = 2L 24 | 25 | @Before 26 | fun `before each`() { 27 | jdbcTemplate.update("delete from quizzes cascade") 28 | jdbcTemplate.update("delete from users cascade") 29 | jdbcTemplate.resetSerial() 30 | 31 | userRepository = UserRepository(jdbcTemplate, passwordEncoder) 32 | quizRepository = QuizRepository(jdbcTemplate) 33 | 34 | userRepository.create(kate) 35 | userRepository.create(john) 36 | } 37 | 38 | @Test 39 | fun `create - creates new quiz`() { 40 | // ACT 41 | quizRepository.create(quizOne.copy(userId = 1)) 42 | 43 | // ASSERT 44 | val quizzes = quizRepository.findAll() 45 | assertThat(quizzes).isEqualTo(listOf( 46 | quizOne.copy(id = 3, userId = 1) 47 | )) 48 | } 49 | 50 | @Test 51 | fun `create - creates other quiz`() { 52 | // ACT 53 | quizRepository.create(quizTwo.copy(userId = 2)) 54 | 55 | // ASSERT 56 | val quizzes = quizRepository.findAll() 57 | assertThat(quizzes).isEqualTo(listOf( 58 | quizTwo.copy(id = 3, userId = 2) 59 | )) 60 | } 61 | 62 | @Test 63 | fun `findAll - finds multiple quizzes`() { 64 | // ARRANGE 65 | quizRepository.create(quizOne.copy(userId = 2)) 66 | quizRepository.create(quizTwo.copy(userId = 2)) 67 | 68 | // ACT 69 | val quizzes = quizRepository.findAll() 70 | 71 | // ASSERT 72 | assertThat(quizzes).isEqualTo(listOf( 73 | quizOne.copy(id = 3, userId = 2), 74 | quizTwo.copy(id = 4, userId = 2) 75 | )) 76 | } 77 | 78 | @Test 79 | fun `findAll - finds multiple quizzes with different starting serial`() { 80 | // ARRANGE 81 | jdbcTemplate.resetSerial(to = 42) 82 | quizRepository.create(quizTwo.copy(userId = 1)) 83 | quizRepository.create(quizOne.copy(userId = 1)) 84 | 85 | // ACT 86 | val quizzes = quizRepository.findAll() 87 | 88 | // ASSERT 89 | assertThat(quizzes).isEqualTo(listOf( 90 | quizTwo.copy(id = 42, userId = 1), 91 | quizOne.copy(id = 43, userId = 1) 92 | )) 93 | } 94 | 95 | @Test 96 | fun `findMostRecentByUserId - finds no quizzes when user doesn't have any`() { 97 | // ARRANGE 98 | quizRepository.create(quizOne.copy(userId = 1)) 99 | quizRepository.create(quizTwo.copy(userId = 2)) 100 | 101 | // ACT 102 | val quizzes = quizRepository.findMostRecentByUserId(42) 103 | 104 | // ASSERT 105 | assertThat(quizzes).isEmpty() 106 | } 107 | 108 | @Test 109 | fun `findMostRecentByUserId - finds a single quiz when there is only one`() { 110 | // ARRANGE 111 | quizRepository.create(quizOne.copy(userId = 1)) 112 | quizRepository.create(quizTwo.copy(userId = 2)) 113 | 114 | // ACT 115 | val quizzes = quizRepository.findMostRecentByUserId(1) 116 | 117 | // ASSERT 118 | assertThat(quizzes).isEqualTo(listOf( 119 | quizOne.copy(id = 3, userId = 1) 120 | )) 121 | } 122 | 123 | @Test 124 | fun `findMostRecentByUserId - finds multiple quizzes when there are multiple`() { 125 | // ARRANGE 126 | quizRepository.create(quizOne.copy(userId = 1, createdAt = yesterday)) 127 | quizRepository.create(quizTwo.copy(userId = 1, createdAt = today)) 128 | 129 | // ACT 130 | val quizzes = quizRepository.findMostRecentByUserId(1) 131 | 132 | // ASSERT 133 | assertThat(quizzes).isEqualTo(listOf( 134 | quizTwo.copy(id = 4, userId = 1, createdAt = today), 135 | quizOne.copy(id = 3, userId = 1, createdAt = yesterday) 136 | )) 137 | } 138 | 139 | @Test 140 | fun `findMostRecentByUserId - finds multiple quizzes when there are multiple when ordered differently`() { 141 | // ARRANGE 142 | quizRepository.create(quizOne.copy(userId = 1, createdAt = today)) 143 | quizRepository.create(quizTwo.copy(userId = 1, createdAt = yesterday)) 144 | 145 | // ACT 146 | val quizzes = quizRepository.findMostRecentByUserId(1) 147 | 148 | // ASSERT 149 | assertThat(quizzes).isEqualTo(listOf( 150 | quizOne.copy(id = 3, userId = 1, createdAt = today), 151 | quizTwo.copy(id = 4, userId = 1, createdAt = yesterday) 152 | )) 153 | } 154 | 155 | @Test 156 | fun `findByIdAndUserId - finds quiz by id and userId`() { 157 | // ARRANGE 158 | quizRepository.create(quizOne.copy(userId = kateId)) 159 | quizRepository.create(quizTwo.copy(userId = kateId)) 160 | 161 | // ACT 162 | val actualQuiz = quizRepository.findByIdAndUserId(4, kateId) 163 | 164 | // ASSERT 165 | val expectedQuiz = quizTwo.copy(id = 4, userId = kateId) 166 | assertThat(actualQuiz).isEqualTo(expectedQuiz) 167 | } 168 | 169 | @Test 170 | fun `findByIdAndUserId - finds different quiz by id and userId`() { 171 | // ARRANGE 172 | quizRepository.create(quizOne.copy(userId = kateId)) 173 | quizRepository.create(quizTwo.copy(userId = kateId)) 174 | 175 | // ACT 176 | val actualQuiz = quizRepository.findByIdAndUserId(3, kateId) 177 | 178 | // ASSERT 179 | val expectedQuiz = quizOne.copy(id = 3, userId = kateId) 180 | assertThat(actualQuiz).isEqualTo(expectedQuiz) 181 | } 182 | 183 | @Test 184 | fun `findByIdAndUserId - finds nothing when user does not match`() { 185 | // ARRANGE 186 | quizRepository.create(quizOne.copy(userId = kateId)) 187 | quizRepository.create(quizTwo.copy(userId = kateId)) 188 | 189 | // ACT 190 | val actualQuiz = quizRepository.findByIdAndUserId(3, johnId) 191 | 192 | // ASSERT 193 | assertThat(actualQuiz).isNull() 194 | } 195 | 196 | } 197 | -------------------------------------------------------------------------------- /src/test/kotlin/app/quiz/QuizServiceTest.kt: -------------------------------------------------------------------------------- 1 | package app.quiz 2 | 3 | import app.auth.CurrentUser 4 | import app.quiz.images.ImageRepository 5 | import app.quiz.images.ImageUploadException 6 | import app.quiz.standardQuizzes.quizOne 7 | import app.quiz.standardQuizzes.quizTwo 8 | import app.auth.user.standardUsers.kate 9 | import app.util.TimeProvider 10 | import com.nhaarman.mockito_kotlin.* 11 | import helpers.today 12 | import org.assertj.core.api.Assertions.assertThat 13 | import org.assertj.core.api.Assertions.assertThatThrownBy 14 | import org.junit.Test 15 | 16 | class QuizServiceTest { 17 | 18 | private val quizRepository = mock() 19 | private val imageRepository = mock() 20 | 21 | private val timeProvider = mock { 22 | on { now() } doReturn today 23 | } 24 | 25 | private val quizService = QuizService(quizRepository, imageRepository, timeProvider) 26 | 27 | @Test 28 | fun `createQuiz - saves an image and creates the quiz`() { 29 | // ARRANGE 30 | val image = "an image".toByteArray() 31 | val request = CreateQuizRequest( 32 | userId = 24, 33 | title = "a title", 34 | image = image, 35 | description = "a description", 36 | durationInMinutes = 7, 37 | cta = "a cta" 38 | ) 39 | 40 | val imageUrl = "http://images.example.org/12/3456789.png" 41 | given(imageRepository.upload(image)).willReturn(imageUrl) 42 | 43 | // ACT 44 | quizService.createQuiz(request) 45 | 46 | // ASSERT 47 | verify(imageRepository).upload(image) 48 | verify(quizRepository).create(Quiz( 49 | userId = request.userId, 50 | title = request.title, 51 | imageUrl = imageUrl, 52 | description = request.description, 53 | durationInMinutes = request.durationInMinutes, 54 | cta = request.cta, 55 | createdAt = today, 56 | updatedAt = today 57 | )) 58 | } 59 | 60 | @Test 61 | fun `createQuiz - propagates image upload failure`() { 62 | // ARRANGE 63 | val image = "an image".toByteArray() 64 | val request = CreateQuizRequest( 65 | userId = 42, 66 | title = "a title", 67 | image = image, 68 | description = "a description", 69 | durationInMinutes = 7, 70 | cta = "a cta" 71 | ) 72 | 73 | val uploadError = RuntimeException("image service is down") 74 | given(imageRepository.upload(image)).willThrow(uploadError) 75 | 76 | // ACT 77 | assertThatThrownBy { quizService.createQuiz(request) } 78 | .isInstanceOf(ImageUploadException::class.java) 79 | .hasCause(uploadError) 80 | 81 | // ASSERT 82 | verify(imageRepository).upload(image) 83 | verifyZeroInteractions(quizRepository) 84 | } 85 | 86 | @Test 87 | fun `getMostRecentQuizzesForUser - fetches quizzes for that user`() = 88 | listOf(42L, 37L).forEach { userId -> 89 | // ARRANGE 90 | val currentUser = CurrentUser(id = userId, name = "irrelevant") 91 | given(quizRepository.findMostRecentByUserId(userId)) 92 | .willReturn(listOf(quizOne, quizTwo)) 93 | 94 | // ACT 95 | val quizzes = quizService.getMostRecentQuizzesForUser(currentUser) 96 | 97 | // ASSERT 98 | assertThat(quizzes).isEqualTo(listOf(quizOne, quizTwo)) 99 | } 100 | 101 | @Test 102 | fun `getQuizForEditing - finds and returns the quiz`() { 103 | // ARRANGE 104 | val quizId = 31L 105 | val currentUser = CurrentUser(id = 18, name = kate.name) 106 | val quiz = quizTwo.copy(id = quizId, userId = currentUser.id) 107 | given(quizRepository.findByIdAndUserId(id = quizId, userId = currentUser.id)) 108 | .willReturn(quiz) 109 | 110 | // ACT 111 | val actualQuiz = quizService.getQuizForEditing(id = quizId, currentUser = currentUser) 112 | 113 | // ASSERT 114 | assertThat(actualQuiz).isEqualTo(quiz) 115 | } 116 | 117 | @Test 118 | fun `getQuizForEditing - fails when quiz was not foudn`() { 119 | // ARRANGE 120 | val quizId = 31L 121 | val currentUser = CurrentUser(id = 18, name = kate.name) 122 | given(quizRepository.findByIdAndUserId(id = quizId, userId = currentUser.id)) 123 | .willReturn(null) 124 | 125 | // ACT 126 | val result = assertThatThrownBy { 127 | quizService.getQuizForEditing(id = quizId, currentUser = currentUser) 128 | } 129 | 130 | // ASSERT 131 | result.isInstanceOf(QuizNotFoundException::class.java) 132 | } 133 | 134 | 135 | } -------------------------------------------------------------------------------- /src/test/kotlin/app/quiz/createQuizFactory.kt: -------------------------------------------------------------------------------- 1 | package app.quiz 2 | 3 | import helpers.today 4 | import java.time.LocalDateTime 5 | 6 | fun Quiz.Companion.create( 7 | userId: Long = 42, 8 | title: String = "irrelevant", 9 | imageUrl: String = "irrelevant", 10 | description: String = "irrelevant", 11 | durationInMinutes: Int = 3, 12 | cta: String = "irrelevant", 13 | createdAt: LocalDateTime = today, 14 | updatedAt: LocalDateTime = today 15 | ) = Quiz( 16 | userId = userId, 17 | title = title, 18 | imageUrl = imageUrl, 19 | description = description, 20 | durationInMinutes = durationInMinutes, 21 | cta = cta, 22 | createdAt = createdAt, 23 | updatedAt = updatedAt 24 | ) 25 | 26 | @Suppress("ClassName") 27 | object standardQuizzes { 28 | 29 | val quizOne = Quiz.create( 30 | userId = 1, 31 | title = "title one", 32 | imageUrl = "image one", 33 | description = "description one", 34 | durationInMinutes = 3, 35 | cta = "cta one" 36 | ) 37 | 38 | val quizTwo = Quiz.create( 39 | userId = 2, 40 | title = "title two", 41 | imageUrl = "image two", 42 | description = "description two", 43 | durationInMinutes = 6, 44 | cta = "cta two" 45 | ) 46 | 47 | val all = listOf(quizOne, quizTwo) 48 | 49 | } 50 | -------------------------------------------------------------------------------- /src/test/kotlin/app/quiz/image/FileSystemImageRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | package app.quiz.image 2 | 3 | import app.quiz.images.FileSystemImageRepository 4 | import app.util.UuidProvider 5 | import com.nhaarman.mockito_kotlin.doReturn 6 | import com.nhaarman.mockito_kotlin.mock 7 | import org.assertj.core.api.Assertions.assertThat 8 | import org.junit.Before 9 | import org.junit.Test 10 | import java.io.File 11 | 12 | class FileSystemImageRepositoryTest { 13 | 14 | private val uuidProvider = mock { 15 | on { generateUuid() } doReturn "55aa4f35-0920-4285-9d00-43d284085062" 16 | } 17 | 18 | private val imageRepository = FileSystemImageRepository(uuidProvider) 19 | 20 | @Before 21 | fun `before each`() { 22 | File("./uploads").deleteRecursively() 23 | } 24 | 25 | @Test 26 | fun `upload - returns url`() { 27 | // ARRANGE 28 | val image = "some image".toByteArray() 29 | 30 | // ACT 31 | val url = imageRepository.upload(image) 32 | 33 | // ASSERT 34 | assertThat(url).isEqualTo( 35 | "/uploads/55/aa4f35-0920-4285-9d00-43d284085062.png" 36 | ) 37 | } 38 | 39 | @Test 40 | fun `upload - stores the file`() { 41 | // ARRANGE 42 | val image = "some image".toByteArray() 43 | 44 | // ACT 45 | val url = imageRepository.upload(image) 46 | 47 | // ASSERT 48 | val file = File(".$url") 49 | assertThat(file.readBytes()).isEqualTo(image) 50 | } 51 | 52 | } -------------------------------------------------------------------------------- /src/test/kotlin/featuretests/auth/ConfirmationPage.kt: -------------------------------------------------------------------------------- 1 | package featuretests.auth 2 | 3 | import org.fluentlenium.core.FluentPage 4 | import org.fluentlenium.core.domain.FluentWebElement 5 | import org.openqa.selenium.support.FindBy 6 | 7 | class ConfirmationPage : FluentPage() { 8 | 9 | @FindBy(css = """[data-qa="error"]""") 10 | private lateinit var error: FluentWebElement 11 | 12 | fun errorText() = error.text()!! 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/test/kotlin/featuretests/auth/DashboardPage.kt: -------------------------------------------------------------------------------- 1 | package featuretests.auth 2 | 3 | import org.fluentlenium.core.FluentPage 4 | import org.fluentlenium.core.annotation.PageUrl 5 | import org.fluentlenium.core.domain.FluentWebElement 6 | import org.openqa.selenium.support.FindBy 7 | 8 | @PageUrl("/dashboard") 9 | class DashboardPage : FluentPage() { 10 | 11 | @FindBy(css = """[data-qa="welcome"]""") 12 | private lateinit var welcome: FluentWebElement 13 | 14 | @FindBy(css = """[data-qa="sign-out"]""") 15 | private lateinit var signOut: FluentWebElement 16 | 17 | fun welcomeText() = welcome.text()!! 18 | 19 | fun clickOnSignOut() { 20 | signOut.click() 21 | } 22 | } -------------------------------------------------------------------------------- /src/test/kotlin/featuretests/auth/LoginFeatureTest.kt: -------------------------------------------------------------------------------- 1 | package featuretests.auth 2 | 3 | import app.auth.AuthService 4 | import app.auth.user.User 5 | import helpers.FeatureTest 6 | import helpers.today 7 | import org.fluentlenium.assertj.FluentLeniumAssertions.assertThat 8 | import org.fluentlenium.core.FluentControl 9 | import org.junit.Before 10 | import org.junit.Test 11 | import org.springframework.web.util.UriComponentsBuilder 12 | 13 | class LoginFeatureTest : FeatureTest(), LoginFeatureTestHelper { 14 | 15 | @Before 16 | fun `before each`() { 17 | Given(`there are following signed-up users`( 18 | UserEntry(user = "kate@example.org", pass = "welcomekate", name = "Kate"), 19 | UserEntry(user = "john@example.org", pass = "johnwelcome", name = "John") 20 | )) 21 | } 22 | 23 | @Test 24 | fun `login is required`() { 25 | When(`I go to dashboard page`()) 26 | Then(`I see the login page`()) 27 | } 28 | 29 | @Test 30 | fun `correct login`() { 31 | Given(`I am on the login page`()) 32 | When(`I log in with`(user = "kate@example.org", pass = "welcomekate")) 33 | Then(`I see the dashboard`(with = "Welcome, Kate")) 34 | } 35 | 36 | @Test 37 | fun `correct login for other user`() { 38 | Given(`I am on the login page`()) 39 | When(`I log in with`(user = "john@example.org", pass = "johnwelcome")) 40 | Then(`I see the dashboard`(with = "Welcome, John")) 41 | } 42 | 43 | @Test 44 | fun `non-existing user`() { 45 | Given(`I am on the login page`()) 46 | When(`I log in with`(user = "sali@example.org")) 47 | Then(`I see that my credentials are invalid`()) 48 | And(`the user field filled`(with = "sali@example.org")) 49 | And(`the password field is empty`()) 50 | And(`I can try to login again`()) 51 | } 52 | 53 | @Test 54 | fun `existing user with invalid password`() { 55 | Given(`I am on the login page`()) 56 | When(`I log in with`(user = "kate@example.org")) 57 | Then(`I see that my credentials are invalid`()) 58 | And(`the user field filled`(with = "kate@example.org")) 59 | And(`the password field is empty`()) 60 | And(`I can try to login again`()) 61 | } 62 | 63 | private fun `I can try to login again`() = `correct login`() 64 | 65 | } 66 | 67 | interface LoginFeatureTestHelper : FluentControl { 68 | val authService: AuthService 69 | val dashboardPage: DashboardPage 70 | val loginPage: LoginPage 71 | 72 | fun `there are following signed-up users`(vararg entries: UserEntry) { 73 | entries.forEach { 74 | authService.signupUser(User( 75 | email = it.user, 76 | password = it.pass, 77 | name = it.name, 78 | confirmed = true, 79 | createdAt = today, 80 | updatedAt = today 81 | ), baseUrl) 82 | } 83 | } 84 | 85 | fun `I am logged in`(with: String) { 86 | val uri = UriComponentsBuilder 87 | .fromPath("/login/force") 88 | .queryParam("username", with) 89 | .toUriString() 90 | 91 | goTo(uri) 92 | 93 | `I see the dashboard`(with = "Welcome") 94 | } 95 | 96 | fun `I go to dashboard page`() { 97 | goTo(dashboardPage) 98 | } 99 | 100 | fun `I am on the dashboard page`() = `I go to dashboard page`() 101 | 102 | fun `I see the login page`(with: String? = null) { 103 | assertThat(loginPage).isAt 104 | 105 | if (with != null) { 106 | assertThat(loginPage.signOutText()).contains(with) 107 | } 108 | } 109 | 110 | fun `I log in with`(user: String, pass: String = "irrelevant") { 111 | loginPage.login(user, pass) 112 | } 113 | 114 | fun `I am on the login page`() { 115 | goTo(loginPage) 116 | } 117 | 118 | fun `I see the dashboard`(with: String? = null) { 119 | assertThat(dashboardPage).isAt 120 | 121 | if (with != null) { 122 | assertThat(dashboardPage.welcomeText()).contains(with) 123 | } 124 | } 125 | 126 | fun `I see that my credentials are invalid`() { 127 | assertThat(loginPage.errorText()).contains("Bad credentials") 128 | } 129 | 130 | fun `the user field filled`(with: String) { 131 | assertThat(loginPage.userInputValue()).isEqualTo(with) 132 | } 133 | 134 | fun `the password field is empty`() { 135 | assertThat(loginPage.passwordInputValue()).isEqualTo("") 136 | } 137 | } 138 | 139 | data class UserEntry(val user: String = "irrelevant@example.org", 140 | val pass: String = "irrelevant", 141 | val name: String = "irrelevant") 142 | -------------------------------------------------------------------------------- /src/test/kotlin/featuretests/auth/LoginPage.kt: -------------------------------------------------------------------------------- 1 | package featuretests.auth 2 | 3 | import org.fluentlenium.core.FluentPage 4 | import org.fluentlenium.core.annotation.PageUrl 5 | import org.fluentlenium.core.domain.FluentWebElement 6 | import org.openqa.selenium.support.FindBy 7 | 8 | @PageUrl("/login") 9 | class LoginPage : FluentPage() { 10 | 11 | @FindBy(css = """[data-qa="user-input"]""") 12 | private lateinit var userInput: FluentWebElement 13 | 14 | @FindBy(css = """[data-qa="pass-input"]""") 15 | private lateinit var passwordInput: FluentWebElement 16 | 17 | @FindBy(css = """[data-qa="submit-button"]""") 18 | private lateinit var submitButton: FluentWebElement 19 | 20 | @FindBy(css = """[data-qa="error"]""") 21 | private lateinit var error: FluentWebElement 22 | 23 | @FindBy(css = """[data-qa="sign-out-info"]""") 24 | private lateinit var signOutInfo: FluentWebElement 25 | 26 | @FindBy(css = """[data-qa="create-account"]""") 27 | private lateinit var createAccount: FluentWebElement 28 | 29 | @FindBy(css = """[data-qa="resend-confirmation"]""") 30 | private lateinit var resendConfirmation: FluentWebElement 31 | 32 | fun login(user: String, pass: String) { 33 | userInput.fill().with(user) 34 | passwordInput.fill().with(pass) 35 | submitButton.click() 36 | } 37 | 38 | fun errorText() = error.text()!! 39 | fun signOutText() = signOutInfo.text()!! 40 | 41 | fun userInputValue(): String = userInput.value() 42 | fun passwordInputValue(): String = passwordInput.value() 43 | 44 | fun clickOnCreateAccount() = createAccount.click()!! 45 | fun clickOnResendConfirmationLink() = resendConfirmation.click()!! 46 | } -------------------------------------------------------------------------------- /src/test/kotlin/featuretests/auth/LogoutFeatureTest.kt: -------------------------------------------------------------------------------- 1 | package featuretests.auth 2 | 3 | import helpers.FeatureTest 4 | import org.junit.Test 5 | 6 | class LogoutFeatureTest : FeatureTest(), LogoutFeatureTestHelper { 7 | @Test 8 | fun `signing out`() { 9 | Given(`there are following signed-up users`( 10 | UserEntry(user = "kate@example.org", name = "Kate") 11 | )) 12 | And(`I am on the login page`()) 13 | 14 | When(`I log in with`(user = "kate@example.org")) 15 | And(`I click on the sign out button`()) 16 | Then(`I see the login page`(with = "You have been signed out successfully")) 17 | 18 | When(`I go to dashboard page`()) 19 | Then(`I see the login page`()) 20 | } 21 | } 22 | 23 | interface LogoutFeatureTestHelper : LoginFeatureTestHelper { 24 | fun `I click on the sign out button`() { 25 | dashboardPage.clickOnSignOut() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/test/kotlin/featuretests/auth/SignupPage.kt: -------------------------------------------------------------------------------- 1 | package featuretests.auth 2 | 3 | import org.fluentlenium.core.FluentPage 4 | import org.fluentlenium.core.annotation.PageUrl 5 | import org.fluentlenium.core.domain.FluentWebElement 6 | import org.openqa.selenium.support.FindBy 7 | 8 | @PageUrl("/signup") 9 | class SignupPage : FluentPage() { 10 | 11 | @FindBy(css = """[data-qa="login"]""") 12 | private lateinit var login: FluentWebElement 13 | 14 | @FindBy(css = """[data-qa="user-input"]""") 15 | private lateinit var userInput: FluentWebElement 16 | 17 | @FindBy(css = """[data-qa="user-validation"]""") 18 | private lateinit var userValidation: FluentWebElement 19 | 20 | @FindBy(css = """[data-qa="name-input"]""") 21 | private lateinit var nameInput: FluentWebElement 22 | 23 | @FindBy(css = """[data-qa="name-validation"]""") 24 | private lateinit var nameValidation: FluentWebElement 25 | 26 | @FindBy(css = """[data-qa="pass-input"]""") 27 | private lateinit var passInput: FluentWebElement 28 | 29 | @FindBy(css = """[data-qa="pass-validation"]""") 30 | private lateinit var passValidation: FluentWebElement 31 | 32 | @FindBy(css = """[data-qa="confirm-input"]""") 33 | private lateinit var confirmInput: FluentWebElement 34 | 35 | @FindBy(css = """[data-qa="confirm-validation"]""") 36 | private lateinit var confirmValidation: FluentWebElement 37 | 38 | @FindBy(css = """[data-qa="submit-button"]""") 39 | private lateinit var submitButton: FluentWebElement 40 | 41 | @FindBy(css = """[data-qa="error"]""") 42 | private lateinit var error: FluentWebElement 43 | 44 | fun clickOnLogin() { 45 | login.click() 46 | } 47 | 48 | fun signup(user: String, name: String, pass: String, confirm: String) { 49 | userInput.fill().with(user) 50 | nameInput.fill().with(name) 51 | passInput.fill().with(pass) 52 | confirmInput.fill().with(confirm) 53 | submitButton.click() 54 | } 55 | 56 | fun errorText() = error.text()!! 57 | fun userValue() = userInput.value()!! 58 | fun nameValue() = nameInput.value()!! 59 | fun passValue() = passInput.value()!! 60 | fun confirmValue() = confirmInput.value()!! 61 | 62 | fun userValidationText() = userValidation.text()!! 63 | fun nameValidationText() = nameValidation.text()!! 64 | fun passValidationText() = passValidation.text()!! 65 | fun confirmValidationText() = confirmValidation.text()!! 66 | 67 | } -------------------------------------------------------------------------------- /src/test/kotlin/featuretests/auth/ThankYouPage.kt: -------------------------------------------------------------------------------- 1 | package featuretests.auth 2 | 3 | import org.fluentlenium.core.FluentPage 4 | import org.fluentlenium.core.annotation.PageUrl 5 | import org.fluentlenium.core.domain.FluentWebElement 6 | import org.openqa.selenium.support.FindBy 7 | 8 | @PageUrl("/thank-you") 9 | class ThankYouPage : FluentPage() { 10 | 11 | @FindBy(css = """[data-qa="header"]""") 12 | private lateinit var header: FluentWebElement 13 | 14 | fun headerText() = header.text()!! 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/test/kotlin/featuretests/quiz/CreateQuestionFeatureTest.kt: -------------------------------------------------------------------------------- 1 | package featuretests.quiz 2 | 3 | import app.quiz.Quiz 4 | import app.quiz.create 5 | import featuretests.auth.LoginFeatureTestHelper 6 | import featuretests.auth.UserEntry 7 | import helpers.FeatureTest 8 | import org.fluentlenium.assertj.FluentLeniumAssertions.assertThat 9 | import org.junit.Before 10 | import org.junit.Test 11 | 12 | class CreateQuestionFeatureTest : FeatureTest(), CreateQuestionFeatureTestHelper { 13 | @Before 14 | fun `before each`() { 15 | Given(`there are following signed-up users`( 16 | UserEntry(user = "kate@example.org") 17 | )) 18 | And(`I am logged in`(with = "kate@example.org")) 19 | And(`I have created the following quizzes`( 20 | Quiz.create(title = "My quiz", userId = 1) 21 | )) 22 | } 23 | 24 | @Test 25 | fun `showing the quiz page`() { 26 | Given(`I am on the quiz list page`()) 27 | When(`I click on the quiz edit button for`(quiz = "My quiz")) 28 | Then(`I see the quiz edit page with`(title = "My quiz")) 29 | And(`I see that there are no questions`()) 30 | } 31 | } 32 | 33 | interface CreateQuestionFeatureTestHelper : LoginFeatureTestHelper, CreateQuizFeatureTestHelper { 34 | val quizEditPage: QuizEditPage 35 | 36 | fun `I click on the quiz edit button for`(quiz: String) { 37 | quizListPage.clickOnEditQuiz(quiz) 38 | } 39 | 40 | fun `I see the quiz edit page with`(title: String) { 41 | val quizId = 2 42 | quizEditPage.isAt(quizId) 43 | 44 | assertThat(quizEditPage.titleValue()).isEqualTo(title) 45 | } 46 | 47 | fun `I see that there are no questions`() { 48 | quizEditPage.assertNoTitles() 49 | } 50 | } -------------------------------------------------------------------------------- /src/test/kotlin/featuretests/quiz/CreateQuizFeatureTest.kt: -------------------------------------------------------------------------------- 1 | package featuretests.quiz 2 | 3 | import app.quiz.CreateQuizRequest 4 | import app.quiz.Quiz 5 | import app.quiz.QuizService 6 | import featuretests.auth.LoginFeatureTestHelper 7 | import featuretests.auth.UserEntry 8 | import helpers.FeatureTest 9 | import org.fluentlenium.assertj.FluentLeniumAssertions.assertThat 10 | import org.junit.Before 11 | import org.junit.Test 12 | import java.io.File 13 | 14 | class CreateQuizFeatureTest : FeatureTest(), CreateQuizFeatureTestHelper { 15 | 16 | @Before 17 | fun setUp() { 18 | Given(`there are following signed-up users`( 19 | UserEntry(user = "sarah@example.org") 20 | )) 21 | And(`I am logged in`(with = "sarah@example.org")) 22 | } 23 | 24 | @Test 25 | fun `initiating creation of quiz from the quiz list page`() { 26 | Given(`I am on the quiz list page`()) 27 | When(`I click on create new quiz button`()) 28 | Then(`I see the new quiz page`()) 29 | } 30 | 31 | @Test 32 | fun `creating the quiz`() { 33 | Given(`I am on the new quiz page`()) 34 | 35 | When(`I enter the title`("How much do you actually know about extension functions in Kotlin?")) 36 | And(`I upload an image`("extension_functions_quiz.png")) 37 | And(`I enter the description`( 38 | "Answer these 7 questions, and see what great features of " + 39 | "extension function in Kotlin you are missing out on!")) 40 | And(`I select the quiz duration`("3 min.")) 41 | And(`I enter the CTA text`("Take a quiz and learn!")) 42 | And(`I click on the submit button`()) 43 | 44 | Then(`I see the quiz list page`()) 45 | And(`I see the quiz with`( 46 | title = "How much do you actually know about extension functions in Kotlin?", 47 | image = "extension_functions_quiz.png", 48 | description = "Answer these 7 questions, and see what great features of " + 49 | "extension function in Kotlin you are missing out on!", 50 | duration = "It will take you only 3 minutes!", 51 | cta = "Take a quiz and learn!", 52 | ctaUrl = "/quizzes/2" 53 | )) 54 | } 55 | 56 | // sad paths 57 | @Test 58 | fun `empty title`() { 59 | When(`I create a quiz with`(title = "")) 60 | Then(`I still see the new quiz page`()) 61 | And(`I see that the title can't be empty`()) 62 | } 63 | 64 | @Test 65 | fun `empty description`() { 66 | When(`I create a quiz with`(description = "")) 67 | Then(`I still see the new quiz page`()) 68 | And(`I see that the description can't be empty`()) 69 | } 70 | 71 | @Test 72 | fun `empty duration`() { 73 | When(`I create a quiz with`(duration = null)) 74 | Then(`I still see the new quiz page`()) 75 | And(`I see that the duration can't be empty`()) 76 | } 77 | 78 | @Test 79 | fun `empty cta`() { 80 | When(`I create a quiz with`(cta = "")) 81 | Then(`I still see the new quiz page`()) 82 | And(`I see that the cta can't be empty`()) 83 | } 84 | 85 | @Test 86 | fun `empty image`() { 87 | When(`I create a quiz with`(image = null)) 88 | Then(`I still see the new quiz page`()) 89 | And(`I see that the image can't be empty`()) 90 | } 91 | 92 | } 93 | 94 | interface CreateQuizFeatureTestHelper : LoginFeatureTestHelper { 95 | val newQuizPage: NewQuizPage 96 | val quizService: QuizService 97 | val quizListPage: QuizListPage 98 | 99 | fun `I am on the new quiz page`() { 100 | goTo(newQuizPage) 101 | } 102 | 103 | fun `I have created the following quizzes`(vararg quizzes: Quiz) { 104 | quizzes.forEach { 105 | quizService.createQuiz(CreateQuizRequest( 106 | userId = 1, 107 | title = it.title, 108 | image = "".toByteArray(), 109 | description = it.description, 110 | durationInMinutes = it.durationInMinutes, 111 | cta = it.cta 112 | )) 113 | } 114 | } 115 | 116 | fun `I go to quiz list page`() { 117 | goTo(quizListPage) 118 | } 119 | 120 | fun `I am on the quiz list page`() = `I go to quiz list page`() 121 | 122 | fun `I click on create new quiz button`() { 123 | quizListPage.clickOnNewQuiz() 124 | } 125 | 126 | fun `I see the new quiz page`() { 127 | assertThat(newQuizPage).isAt 128 | } 129 | 130 | fun `I still see the new quiz page`() { 131 | assertThat(newQuizPage.afterSubmit).isAt 132 | } 133 | 134 | fun `I enter the title`(title: String) { 135 | newQuizPage.enterTitle(title) 136 | } 137 | 138 | fun `I upload an image`(image: String) { 139 | newQuizPage.uploadImage(imagePath(image)) 140 | } 141 | 142 | fun `I enter the description`(description: String) { 143 | newQuizPage.enterDescription(description) 144 | } 145 | 146 | fun `I select the quiz duration`(duration: String) { 147 | newQuizPage.selectDuration(duration) 148 | } 149 | 150 | fun `I enter the CTA text`(cta: String) { 151 | newQuizPage.enterCtaText(cta) 152 | } 153 | 154 | fun `I click on the submit button`() { 155 | newQuizPage.clickOnSubmit() 156 | } 157 | 158 | fun `I create a quiz with`(title: String = "irrelevant", 159 | image: String? = "extension_functions_quiz.png", 160 | description: String = "irrelevant", 161 | duration: String? = "5 min.", 162 | cta: String = "irrelevant") { 163 | `I am on the new quiz page`() 164 | 165 | `I enter the title`(title) 166 | 167 | if (image != null) { 168 | `I upload an image`(image) 169 | } 170 | 171 | `I enter the description`(description) 172 | 173 | if (duration != null) { 174 | `I select the quiz duration`(duration) 175 | } 176 | 177 | `I enter the CTA text`(cta) 178 | `I click on the submit button`() 179 | } 180 | 181 | fun `I see the quiz list page`() { 182 | assertThat(quizListPage).isAt 183 | } 184 | 185 | fun `I see the quiz with`( 186 | title: String, 187 | image: String, 188 | description: String, 189 | duration: String, 190 | cta: String, 191 | ctaUrl: String 192 | ) { 193 | val view = quizListPage.findQuizBy(title) 194 | 195 | assertThat(view.copy(image = "N/A")).isEqualTo(QuizView( 196 | title = title, 197 | image = "N/A", 198 | description = description, 199 | duration = duration, 200 | cta = cta, 201 | ctaUrl = "$baseUrl$ctaUrl" 202 | )) 203 | 204 | assertCorrectImage( 205 | actualUrl = view.image, 206 | expectedFile = imagePath(image) 207 | ) 208 | } 209 | 210 | fun `I see that the title can't be empty`() { 211 | assertThat(newQuizPage.titleValidationText()).contains("Title can't be empty") 212 | } 213 | 214 | fun `I see that the description can't be empty`() { 215 | assertThat(newQuizPage.descriptionValidationText()).contains("Short description can't be empty") 216 | } 217 | 218 | fun `I see that the duration can't be empty`() { 219 | assertThat(newQuizPage.durationValidationText()).contains("Duration (in minutes) can't be empty") 220 | } 221 | 222 | fun `I see that the cta can't be empty`() { 223 | assertThat(newQuizPage.ctaValidationText()).contains("Call to action (CTA) can't be empty") 224 | } 225 | 226 | fun `I see that the image can't be empty`() { 227 | assertThat(newQuizPage.imageValidationText()).contains("Cover image can't be empty") 228 | } 229 | 230 | private fun assertCorrectImage(actualUrl: String, expectedFile: String) { 231 | goTo(actualUrl) 232 | val actualImage = pageSource() 233 | val expectedImage = File(expectedFile).readText(charset("ISO-8859-1")) 234 | assertThat(actualImage).isEqualTo(expectedImage) 235 | } 236 | 237 | private fun imagePath(image: String): String { 238 | val file = File("src/test/resources/test_images/$image") 239 | return file.absolutePath 240 | } 241 | } 242 | 243 | data class QuizView( 244 | val title: String, 245 | val image: String, 246 | val description: String, 247 | val duration: String, 248 | val cta: String, 249 | val ctaUrl: String 250 | ) 251 | -------------------------------------------------------------------------------- /src/test/kotlin/featuretests/quiz/NewQuizPage.kt: -------------------------------------------------------------------------------- 1 | package featuretests.quiz 2 | 3 | import org.fluentlenium.core.FluentPage 4 | import org.fluentlenium.core.annotation.Page 5 | import org.fluentlenium.core.annotation.PageUrl 6 | import org.fluentlenium.core.domain.FluentWebElement 7 | import org.openqa.selenium.support.FindBy 8 | 9 | @PageUrl("/quizzes/new") 10 | class NewQuizPage : FluentPage() { 11 | 12 | @Page 13 | lateinit var afterSubmit: NewQuizPageAfterSubmitPage 14 | 15 | @FindBy(css = """[data-qa="title-input"]""") 16 | private lateinit var titleInput: FluentWebElement 17 | 18 | @FindBy(css = """[data-qa="title-validation"]""") 19 | private lateinit var titleValidation: FluentWebElement 20 | 21 | @FindBy(css = """[data-qa="image-input"]""") 22 | private lateinit var imageInput: FluentWebElement 23 | 24 | @FindBy(css = """[data-qa="image-validation"]""") 25 | private lateinit var imageValidation: FluentWebElement 26 | 27 | @FindBy(css = """[data-qa="description-input"]""") 28 | private lateinit var descriptionInput: FluentWebElement 29 | 30 | @FindBy(css = """[data-qa="description-validation"]""") 31 | private lateinit var descriptionValidation: FluentWebElement 32 | 33 | @FindBy(css = """[data-qa="duration-select"]""") 34 | private lateinit var durationSelect: FluentWebElement 35 | 36 | @FindBy(css = """[data-qa="duration-validation"]""") 37 | private lateinit var durationValidation: FluentWebElement 38 | 39 | @FindBy(css = """[data-qa="cta-input"]""") 40 | private lateinit var ctaInput: FluentWebElement 41 | 42 | @FindBy(css = """[data-qa="cta-validation"]""") 43 | private lateinit var ctaValidation: FluentWebElement 44 | 45 | @FindBy(css = """[data-qa="submit-button"]""") 46 | private lateinit var submitButton: FluentWebElement 47 | 48 | fun enterTitle(title: String) { 49 | titleInput.fill().with(title) 50 | } 51 | 52 | fun uploadImage(imagePath: String) { 53 | imageInput.fill().with(imagePath) 54 | } 55 | 56 | fun enterDescription(description: String) { 57 | descriptionInput.fill().with(description) 58 | } 59 | 60 | fun selectDuration(duration: String) { 61 | durationSelect.fillSelect().withText(duration) 62 | } 63 | 64 | fun enterCtaText(cta: String) { 65 | ctaInput.fill().with(cta) 66 | } 67 | 68 | fun clickOnSubmit() { 69 | submitButton.click() 70 | } 71 | 72 | fun titleValidationText() = titleValidation.text()!! 73 | fun imageValidationText() = imageValidation.text()!! 74 | fun descriptionValidationText() = descriptionValidation.text()!! 75 | fun durationValidationText() = durationValidation.text()!! 76 | fun ctaValidationText() = ctaValidation.text()!! 77 | } 78 | 79 | @PageUrl("/quizzes") 80 | class NewQuizPageAfterSubmitPage : FluentPage() -------------------------------------------------------------------------------- /src/test/kotlin/featuretests/quiz/QuizEditPage.kt: -------------------------------------------------------------------------------- 1 | package featuretests.quiz 2 | 3 | import helpers.WaitHelper 4 | import org.fluentlenium.assertj.FluentLeniumAssertions 5 | import org.fluentlenium.core.FluentPage 6 | import org.fluentlenium.core.annotation.PageUrl 7 | import org.fluentlenium.core.domain.FluentWebElement 8 | import org.openqa.selenium.TimeoutException 9 | import org.openqa.selenium.support.FindBy 10 | 11 | @PageUrl("/quizzes/{id}/edit") 12 | class QuizEditPage : FluentPage(), WaitHelper { 13 | 14 | @FindBy(css = """[data-qa="title-input"]""") 15 | private lateinit var titleInput: FluentWebElement 16 | 17 | fun titleValue() = titleInput.value()!! 18 | 19 | fun questionTitles() = find("""[data-qa="question"]""") 20 | .map { it.attribute("data-qa-title") } 21 | 22 | fun assertNoTitles() { 23 | awaitAtMostFor(50) { 24 | FluentLeniumAssertions.assertThatThrownBy { questionTitles() } 25 | .isInstanceOf(TimeoutException::class.java) 26 | .hasMessageContaining("""[data-qa="question"]""") 27 | } 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/test/kotlin/featuretests/quiz/QuizListFeatureTest.kt: -------------------------------------------------------------------------------- 1 | package featuretests.quiz 2 | 3 | import app.quiz.Quiz 4 | import app.quiz.standardQuizzes.quizOne 5 | import app.quiz.standardQuizzes.quizTwo 6 | import featuretests.auth.LoginFeatureTestHelper 7 | import featuretests.auth.UserEntry 8 | import helpers.FeatureTest 9 | import helpers.today 10 | import helpers.yesterday 11 | import org.fluentlenium.assertj.FluentLeniumAssertions.assertThat 12 | import org.junit.Test 13 | 14 | class QuizListFeatureTest : FeatureTest(), QuizListFeatureTestHelper { 15 | @Test 16 | fun `lists quizzes in the most-recent-first order`() { 17 | Given(`there are following signed-up users`(UserEntry(user = "kate@example.org"))) 18 | And(`I am logged in`(with = "kate@example.org")) 19 | And(`I have created the following quizzes`( 20 | quizOne.copy(createdAt = yesterday), 21 | quizTwo.copy(createdAt = today) 22 | )) 23 | 24 | When(`I go to quiz list page`()) 25 | 26 | Then(`I see quizzes in the following order`( 27 | quizTwo.copy(createdAt = today), 28 | quizOne.copy(createdAt = yesterday) 29 | )) 30 | } 31 | } 32 | 33 | interface QuizListFeatureTestHelper : LoginFeatureTestHelper, CreateQuizFeatureTestHelper { 34 | fun `I see quizzes in the following order`(vararg quizzes: Quiz) { 35 | assertThat(quizListPage.quizTitles()).isEqualTo( 36 | quizzes.map { it.title } 37 | ) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/test/kotlin/featuretests/quiz/QuizListPage.kt: -------------------------------------------------------------------------------- 1 | package featuretests.quiz 2 | 3 | import org.fluentlenium.core.FluentPage 4 | import org.fluentlenium.core.annotation.PageUrl 5 | import org.fluentlenium.core.domain.FluentWebElement 6 | import org.openqa.selenium.support.FindBy 7 | 8 | @PageUrl("/quizzes") 9 | class QuizListPage : FluentPage() { 10 | 11 | @FindBy(css = """[data-qa="new-quiz"]""") 12 | private lateinit var newQuiz: FluentWebElement 13 | 14 | fun clickOnNewQuiz() { 15 | newQuiz.click() 16 | } 17 | 18 | fun findQuizBy(title: String) = 19 | el("""[data-qa="quiz"][data-qa-title="$title"]""").run { 20 | QuizView( 21 | title = el("""[data-qa="quiz-title"]""").text(), 22 | image = el("""[data-qa="quiz-image"]""").attribute("src"), 23 | description = el("""[data-qa="quiz-description"]""").text(), 24 | duration = el("""[data-qa="quiz-duration"]""").text(), 25 | cta = el("""[data-qa="quiz-cta"]""").text(), 26 | ctaUrl = el("""[data-qa="quiz-cta"]""").attribute("href") 27 | ) 28 | } 29 | 30 | fun quizTitles() = find("""[data-qa="quiz"]""") 31 | .map { it.attribute("data-qa-title") } 32 | 33 | fun clickOnEditQuiz(title: String) { 34 | el("""[data-qa="quiz"][data-qa-title="$title"]""") 35 | .el("""[data-qa="edit-button"]""") 36 | .click() 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/test/kotlin/helpers/EmailTest.kt: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import app.email.EmailService 4 | import app.email.EmailTemplate 5 | import org.junit.runner.RunWith 6 | import org.springframework.beans.factory.annotation.Autowired 7 | import org.springframework.boot.autoconfigure.thymeleaf.ThymeleafAutoConfiguration 8 | import org.springframework.boot.test.context.SpringBootTest 9 | import org.springframework.boot.test.mock.mockito.MockBean 10 | import org.springframework.test.context.ActiveProfiles 11 | import org.springframework.test.context.junit4.SpringRunner 12 | 13 | @RunWith(SpringRunner::class) 14 | @ActiveProfiles("test") 15 | @SpringBootTest(classes = [EmailTemplate::class, ThymeleafAutoConfiguration::class]) 16 | abstract class EmailTest { 17 | 18 | @Autowired 19 | protected lateinit var emailTemplate: EmailTemplate 20 | 21 | @MockBean 22 | protected lateinit var emailService: EmailService 23 | 24 | } -------------------------------------------------------------------------------- /src/test/kotlin/helpers/FeatureTest.kt: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import app.Application 4 | import app.auth.AuthService 5 | import app.email.TestEmailService 6 | import app.quiz.QuizService 7 | import featuretests.auth.DashboardPage 8 | import featuretests.auth.ConfirmationPage 9 | import featuretests.auth.LoginPage 10 | import featuretests.auth.SignupPage 11 | import featuretests.auth.ThankYouPage 12 | import featuretests.quiz.NewQuizPage 13 | import featuretests.quiz.QuizEditPage 14 | import featuretests.quiz.QuizListPage 15 | import org.fluentlenium.adapter.junit.FluentTest 16 | import org.fluentlenium.configuration.FluentConfiguration 17 | import org.fluentlenium.core.annotation.Page 18 | import org.fluentlenium.core.hook.wait.Wait 19 | import org.junit.Before 20 | import org.junit.runner.RunWith 21 | import org.springframework.beans.factory.annotation.Autowired 22 | import org.springframework.boot.test.context.SpringBootTest 23 | import org.springframework.boot.web.server.LocalServerPort 24 | import org.springframework.jdbc.core.JdbcTemplate 25 | import org.springframework.test.context.ActiveProfiles 26 | import org.springframework.test.context.junit4.SpringRunner 27 | 28 | @RunWith(SpringRunner::class) 29 | @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = [Application::class]) 30 | @ActiveProfiles("test") 31 | @FluentConfiguration(webDriver = "htmlunit") 32 | @Wait 33 | abstract class FeatureTest : FluentTest() { 34 | @LocalServerPort 35 | lateinit var serverPort: String 36 | 37 | override fun getBaseUrl() = "http://localhost:$serverPort" 38 | 39 | @Page 40 | lateinit var loginPage: LoginPage 41 | 42 | @Page 43 | lateinit var signupPage: SignupPage 44 | 45 | @Page 46 | lateinit var thankYouPage: ThankYouPage 47 | 48 | @Page 49 | lateinit var dashboardPage: DashboardPage 50 | 51 | @Page 52 | lateinit var confirmationPage: ConfirmationPage 53 | 54 | @Page 55 | lateinit var quizListPage: QuizListPage 56 | 57 | @Page 58 | lateinit var newQuizPage: NewQuizPage 59 | 60 | @Page 61 | lateinit var quizEditPage: QuizEditPage 62 | 63 | @Autowired 64 | lateinit var authService: AuthService 65 | 66 | @Autowired 67 | lateinit var emailService: TestEmailService 68 | 69 | @Autowired 70 | lateinit var quizService: QuizService 71 | 72 | @Autowired 73 | private lateinit var jdbcTemplate: JdbcTemplate 74 | 75 | @Suppress("TestFunctionName") 76 | protected fun Given(step: Unit) = Unit 77 | 78 | @Suppress("TestFunctionName") 79 | protected fun When(step: Unit) = Unit 80 | 81 | @Suppress("TestFunctionName") 82 | protected fun Then(step: Unit) = Unit 83 | 84 | @Suppress("TestFunctionName") 85 | protected fun And(step: Unit) = Unit 86 | 87 | @Before 88 | fun clearDatabase() { 89 | jdbcTemplate.update("delete from quizzes") 90 | jdbcTemplate.update("delete from users") 91 | jdbcTemplate.resetSerial() 92 | } 93 | } -------------------------------------------------------------------------------- /src/test/kotlin/helpers/JdbcTemplate.kt: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import org.springframework.jdbc.core.JdbcTemplate 4 | 5 | fun JdbcTemplate.resetSerial(to: Int = 1) { 6 | query("select setval('serial', $to, false)") {} 7 | } -------------------------------------------------------------------------------- /src/test/kotlin/helpers/MockMvcTest.kt: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import org.springframework.test.web.servlet.MockMvc 4 | import org.springframework.test.web.servlet.setup.MockMvcBuilders 5 | import org.springframework.web.servlet.view.InternalResourceViewResolver 6 | 7 | interface MockMvcTest { 8 | fun controller(): Any 9 | 10 | fun mockMvc(): MockMvc { 11 | return MockMvcBuilders 12 | .standaloneSetup(controller()) 13 | .setViewResolvers(InternalResourceViewResolver()) 14 | .build() 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/test/kotlin/helpers/MockitoHelper.kt: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import org.mockito.invocation.InvocationOnMock 4 | 5 | operator fun InvocationOnMock.component1(): Any = arguments[0] 6 | operator fun InvocationOnMock.component2(): Any = arguments[1] 7 | operator fun InvocationOnMock.component3(): Any = arguments[2] 8 | -------------------------------------------------------------------------------- /src/test/kotlin/helpers/RepositoryTest.kt: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import org.junit.runner.RunWith 4 | import org.springframework.beans.factory.annotation.Autowired 5 | import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase 6 | import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest 7 | import org.springframework.jdbc.core.JdbcTemplate 8 | import org.springframework.test.context.ActiveProfiles 9 | import org.springframework.test.context.junit4.SpringRunner 10 | 11 | @RunWith(SpringRunner::class) 12 | @ActiveProfiles("test") 13 | @JdbcTest 14 | @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) 15 | abstract class RepositoryTest { 16 | @Autowired 17 | protected lateinit var jdbcTemplate: JdbcTemplate 18 | } -------------------------------------------------------------------------------- /src/test/kotlin/helpers/WaitHelper.kt: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import org.fluentlenium.core.FluentControl 4 | 5 | interface WaitHelper : FluentControl { 6 | fun awaitAtMostFor(milliseconds: Long, block: () -> Unit) { 7 | val oldAwaitAtMost = awaitAtMost 8 | awaitAtMost = milliseconds 9 | block() 10 | awaitAtMost = oldAwaitAtMost 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/test/kotlin/helpers/dateFactory.kt: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import java.time.LocalDateTime 4 | 5 | val today = LocalDateTime.of(2018, 9, 7, 7, 11)!! 6 | val yesterday = LocalDateTime.of(2018, 9, 6, 7, 22)!! 7 | -------------------------------------------------------------------------------- /src/test/kotlin/templates/emails/ConfirmationTemplateTest.kt: -------------------------------------------------------------------------------- 1 | package templates.emails 2 | 3 | import app.email.EmailMessage 4 | import com.nhaarman.mockito_kotlin.verify 5 | import helpers.EmailTest 6 | import org.junit.Test 7 | 8 | class ConfirmationTemplateTest : EmailTest() { 9 | 10 | @Test 11 | fun `renders a confirmation email given name and link`() { 12 | emailTemplate.send( 13 | from = "from@address.example.org", 14 | to = "kate@example.org", 15 | subject = "Please confirm your account", 16 | html = "emails/confirmation.html", 17 | text = "emails/confirmation.txt", 18 | context = mapOf( 19 | "name" to "Kate", 20 | "confirmUrl" to "https://address.example.org/confirm/valid-code-for-kate" 21 | ) 22 | ) 23 | 24 | verify(emailService).send(EmailMessage( 25 | from = "from@address.example.org", 26 | to = "kate@example.org", 27 | subject = "Please confirm your account", 28 | htmlBody = """ 29 | 30 | 31 | 32 |

Hey, Kate!

33 | 34 |

To complete your signup, please click on the following link:

35 | 36 |

Confirm my account

37 | 38 |

Or copy and paste the following link into your browser: 39 | https://address.example.org/confirm/valid-code-for-kate

40 | 41 |

Thank you,
42 | example.org

43 | 44 | 45 | """.trimIndent(), 46 | textBody = """ 47 | Hey, Kate! 48 | 49 | To complete your signup, please verify your account by copying and pasting the following link into your browser: 50 | 51 | https://address.example.org/confirm/valid-code-for-kate 52 | 53 | Thank you, 54 | example.org 55 | """.trimIndent() 56 | )) 57 | 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/test/resources/application-test.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | url: jdbc:postgresql://localhost/quizzy_test 4 | username: quizzy_test 5 | password: quizzy_test 6 | driver-class-name: org.postgresql.Driver 7 | 8 | passwordEncoder: 9 | strength: 4 -------------------------------------------------------------------------------- /src/test/resources/templates/test_emails/testEmail.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

Hey, Friend!

5 | 6 |

Please click on the following link:

7 | 8 |

Confirm my account

9 | 10 | -------------------------------------------------------------------------------- /src/test/resources/templates/test_emails/testEmail.txt: -------------------------------------------------------------------------------- 1 | Hey, [( ${name} )]! 2 | 3 | Please copy and paste the following link into your browser: 4 | 5 | [( ${confirmUrl} )] -------------------------------------------------------------------------------- /src/test/resources/test_images/extension_functions_quiz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waterlink/kotlin-spring-boot-mvc-starter/e4968ba369ce40258e9d3c069d918430fef015c0/src/test/resources/test_images/extension_functions_quiz.png --------------------------------------------------------------------------------