From 49694090c699b90b4565f051762e5e61dcea85df Mon Sep 17 00:00:00 2001 From: Niklas-23 <65356992+Niklas-23@users.noreply.github.com> Date: Mon, 3 May 2021 19:35:34 +0200 Subject: [PATCH 01/25] Websocket (#78) * Save answers * Websocket send current item * Return PollItemDto instead of PollItem * Save Answers * Update Livepoll.postman_collection.json * Code optimizations * Fix typo Co-authored-by: Marc Auberer Co-authored-by: Marc Auberer --- env/volumes/mysql-data/.gitkeep | 0 env/volumes/mysql-logs/.gitkeep | 0 postman/Livepoll.postman_collection.json | 236 ++++++++++-------- .../de/livepoll/api/config/SecurityConfig.kt | 8 +- .../de/livepoll/api/config/WebSocketConfig.kt | 16 +- .../api/controller/WebSocketController.kt | 11 +- .../api/entity/db/MultipleChoiceItemAnswer.kt | 4 +- .../kotlin/de/livepoll/api/entity/db/Poll.kt | 2 +- .../livepoll/api/entity/db/QuizItemAnswer.kt | 2 +- ...ultipleChoiceItemParticipantAnswerDtoIn.kt | 12 + .../dto/OpenTextItemParticipantAnswerDtoIn.kt | 12 + .../de/livepoll/api/entity/dto/PollDtoIn.kt | 2 +- .../de/livepoll/api/entity/dto/PollDtoOut.kt | 2 +- .../dto/QuizItemParticipantAnswerDtoIn.kt | 12 + .../de/livepoll/api/service/AccountService.kt | 53 ++-- .../api/service/JwtUserDetailsService.kt | 1 - .../livepoll/api/service/PollItemService.kt | 36 +-- .../de/livepoll/api/service/PollService.kt | 78 +++--- .../livepoll/api/service/WebSocketService.kt | 68 ++++- .../de/livepoll/api/util/CustomModelMapper.kt | 4 + .../CustomWebSocketHandshakeHandler.kt | 25 ++ .../websocket/HttpHandshakeInterceptor.kt | 24 ++ .../api/util/websocket/SubscribeListener.kt | 36 +++ 23 files changed, 409 insertions(+), 235 deletions(-) delete mode 100644 env/volumes/mysql-data/.gitkeep delete mode 100644 env/volumes/mysql-logs/.gitkeep create mode 100644 src/main/kotlin/de/livepoll/api/entity/dto/MultipleChoiceItemParticipantAnswerDtoIn.kt create mode 100644 src/main/kotlin/de/livepoll/api/entity/dto/OpenTextItemParticipantAnswerDtoIn.kt create mode 100644 src/main/kotlin/de/livepoll/api/entity/dto/QuizItemParticipantAnswerDtoIn.kt create mode 100644 src/main/kotlin/de/livepoll/api/util/websocket/CustomWebSocketHandshakeHandler.kt create mode 100644 src/main/kotlin/de/livepoll/api/util/websocket/HttpHandshakeInterceptor.kt create mode 100644 src/main/kotlin/de/livepoll/api/util/websocket/SubscribeListener.kt diff --git a/env/volumes/mysql-data/.gitkeep b/env/volumes/mysql-data/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/env/volumes/mysql-logs/.gitkeep b/env/volumes/mysql-logs/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/postman/Livepoll.postman_collection.json b/postman/Livepoll.postman_collection.json index 5ff9a67c..b4349737 100644 --- a/postman/Livepoll.postman_collection.json +++ b/postman/Livepoll.postman_collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "d7d4b436-2510-441b-9a51-65cebe7b60ae", + "_postman_id": "a3857427-e2b1-4190-90c9-664da2489dcd", "name": "Livepoll", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, @@ -89,7 +89,23 @@ "exec": [ "pm.test(\"Status code is 200\", function () {\r", " pm.response.to.have.status(200);\r", - "});" + "});\r", + "\r", + "const urlPollId =\"localhost:8080/v1/polls/\";\r", + "pm.sendRequest(urlPollId, function(error, response){\r", + " let resBody = JSON.parse(new Buffer.from(response.stream).toString())\r", + " pm.globals.set(\"poll-id\", resBody[0].id)\r", + "})" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "pm.globals.unset(\"poll-id\")\r", + "pm.globals.unset(\"poll-item-id\")" ], "type": "text/javascript" } @@ -100,7 +116,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\r\n \"username\": \"postman\",\r\n \"password\": \"abc\"\r\n}", + "raw": "{\r\n \"username\": \"postman\",\r\n \"password\": \"1234\"\r\n}", "options": { "raw": { "language": "json" @@ -198,9 +214,7 @@ "exec": [ "pm.test(\"Status code is 200\", function () {\r", " pm.response.to.have.status(200);\r", - "});\r", - "pm.globals.set(\"poll-id\", pm.response.json()[0].id)\r", - "pm.environment.set(\"poll-id\", pm.response.json()[0].id);" + "});" ], "type": "text/javascript" } @@ -274,8 +288,6 @@ "pm.test(\"Status code is 200\", function () {\r", " pm.response.to.have.status(200);\r", "});\r", - "pm.globals.set(\"poll-item-id\", pm.response.json()[0].id)\r", - "pm.environment.set(\"poll-item-id\", pm.response.json()[0].id);\r", "" ], "type": "text/javascript" @@ -326,7 +338,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\r\n \"name\": \"update\",\r\n \"startDate\": \"1610705932\",\r\n \"endDate\":\"1610705932\",\r\n \"slug\": \"1234\",\r\n \"currentItem\": 1\r\n \r\n}", + "raw": "{\r\n \"name\": \"update\",\r\n \"startDate\": \"1610705932\",\r\n \"endDate\":\"1610705932\",\r\n \"slug\": \"1234\",\r\n \"currentItem\": 51\r\n \r\n}", "options": { "raw": { "language": "json" @@ -334,14 +346,14 @@ } }, "url": { - "raw": "{{base-url}}/v1/polls/2", + "raw": "{{base-url}}/v1/polls/{{poll-id}}", "host": [ "{{base-url}}" ], "path": [ "v1", "polls", - "2" + "{{poll-id}}" ] } }, @@ -445,7 +457,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-quiz-question\",\r\n \"position\": 1,\r\n \"answers\": [\"postman-quiz-answer-1\", \"postman-quiz-answer-2\"]\r\n}", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-quiz-question\",\r\n \"position\": 1,\r\n \"answers\": [ { \"answer\" : \"postman-quiz-answer-1\", \"isCorrect\" : true}, { \"answer\" : \"postman-quiz-answer-2\", \"isCorrect\" : false}]\r\n}", "options": { "raw": { "language": "json" @@ -466,47 +478,6 @@ }, "response": [] }, - { - "name": "Create Open text item", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code is 200\", function () {\r", - " pm.response.to.have.status(200);\r", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"Wie findest du Live-Poll?\",\r\n \"position\": 1\r\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{base-url}}/v1/poll-items/open-text", - "host": [ - "{{base-url}}" - ], - "path": [ - "v1", - "poll-items", - "open-text" - ] - } - }, - "response": [] - }, { "name": "Delete poll item", "event": [ @@ -546,7 +517,7 @@ ] }, { - "name": "Test API", + "name": "Integration Test", "item": [ { "name": "Login", @@ -561,6 +532,18 @@ ], "type": "text/javascript" } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "pm.globals.unset(\"poll-id\")\r", + "pm.globals.unset(\"poll-item-id\")\r", + "pm.globals.unset(\"poll-items\")\r", + "pm.globals.unset(\"poll-items-counter\")" + ], + "type": "text/javascript" + } } ], "request": { @@ -591,6 +574,19 @@ }, { "name": "Get user", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {\r", + " pm.response.to.have.status(200);\r", + "});" + ], + "type": "text/javascript" + } + } + ], "request": { "method": "GET", "header": [], @@ -657,8 +653,7 @@ "pm.test(\"Status code is 200\", function () {\r", " pm.response.to.have.status(200);\r", "});\r", - "pm.globals.set(\"poll-id\", pm.response.json()[0].id)\r", - "pm.environment.set(\"poll-id\", pm.response.json()[0].id);" + "pm.globals.set(\"poll-id\", pm.response.json()[0].id)" ], "type": "text/javascript" } @@ -722,14 +717,14 @@ "response": [] }, { - "name": "Update poll Copy", + "name": "Create multiple choice item", "event": [ { "listen": "test", "script": { "exec": [ - "pm.test(\"Status code is 200\", function () {\r", - " pm.response.to.have.status(200);\r", + "pm.test(\"Status code is 201\", function () {\r", + " pm.response.to.have.status(201);\r", "});" ], "type": "text/javascript" @@ -737,11 +732,11 @@ } ], "request": { - "method": "PUT", + "method": "POST", "header": [], "body": { "mode": "raw", - "raw": "{\r\n \"name\": \"update-postman-poll\",\r\n \"startDate\": \"1610705932\",\r\n \"endDate\":\"1610705932\",\r\n \"slug\": \"1234\",\r\n \"currentItem\": 1\r\n \r\n}", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-multiplechoice-question\",\r\n \"position\": 1,\r\n \"answers\": [\"postman-multiplechoice-answer-1\", \"postman-multiplechoice-answer-2\"]\r\n}", "options": { "raw": { "language": "json" @@ -749,28 +744,28 @@ } }, "url": { - "raw": "{{base-url}}/v1/polls/{{poll-id}}", + "raw": "{{base-url}}/v1/poll-items/multiple-choice", "host": [ "{{base-url}}" ], "path": [ "v1", - "polls", - "{{poll-id}}" + "poll-items", + "multiple-choice" ] } }, "response": [] }, { - "name": "Create multiple choice item", + "name": "Create Quiz Item", "event": [ { "listen": "test", "script": { "exec": [ - "pm.test(\"Status code is 200\", function () {\r", - " pm.response.to.have.status(200);\r", + "pm.test(\"Status code is 201\", function () {\r", + " pm.response.to.have.status(201);\r", "});" ], "type": "text/javascript" @@ -782,7 +777,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-multiplechoice-question\",\r\n \"position\": 1,\r\n \"answers\": [\"postman-multiplechoice-answer-1\", \"postman-multiplechoice-answer-2\"]\r\n}", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-quiz-question\",\r\n \"position\": 1,\r\n \"answers\": [ { \"answer\" : \"postman-quiz-answer-1\", \"isCorrect\" : true}, { \"answer\" : \"postman-quiz-answer-2\", \"isCorrect\" : false}]\r\n}", "options": { "raw": { "language": "json" @@ -790,21 +785,21 @@ } }, "url": { - "raw": "{{base-url}}/v1/poll-items/multiple-choice", + "raw": "{{base-url}}/v1/poll-items/quiz", "host": [ "{{base-url}}" ], "path": [ "v1", "poll-items", - "multiple-choice" + "quiz" ] } }, "response": [] }, { - "name": "Create Quiz Item", + "name": "Get poll items", "event": [ { "listen": "test", @@ -812,40 +807,36 @@ "exec": [ "pm.test(\"Status code is 200\", function () {\r", " pm.response.to.have.status(200);\r", - "});" + "});\r", + "pm.globals.set(\"poll-items\", pm.response.json()) \r", + "let pollItems = pm.globals.get(\"poll-items\")\r", + "pm.globals.set(\"poll-item-id\", pollItems[0].itemId)\r", + "pm.globals.set(\"poll-items-counter\", 1)" ], "type": "text/javascript" } } ], "request": { - "method": "POST", + "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-quiz-question\",\r\n \"position\": 1,\r\n \"answers\": [\"postman-quiz-answer-1\", \"postman-quiz-answer-2\"]\r\n}", - "options": { - "raw": { - "language": "json" - } - } - }, "url": { - "raw": "{{base-url}}/v1/poll-items/quiz", + "raw": "{{base-url}}/v1/polls/{{poll-id}}/poll-items", "host": [ "{{base-url}}" ], "path": [ "v1", - "poll-items", - "quiz" + "polls", + "{{poll-id}}", + "poll-items" ] } }, "response": [] }, { - "name": "Get poll items", + "name": "Get poll item", "event": [ { "listen": "test", @@ -854,8 +845,24 @@ "pm.test(\"Status code is 200\", function () {\r", " pm.response.to.have.status(200);\r", "});\r", - "pm.globals.set(\"poll-item-id\", pm.response.json()[0].itemId)\r", - "pm.environment.set(\"poll-item-id\", pm.response.json()[0].itemId);\r", + "let currentItem = pm.globals.get(\"poll-items-counter\")\r", + "let pollItems = pm.globals.get(\"poll-items\")\r", + "if( currentItem < pollItems.length){\r", + " pm.globals.set(\"poll-item-id\", pollItems[currentItem].itemId)\r", + " currentItem++\r", + " pm.globals.set(\"poll-items-counter\", currentItem)\r", + " postman.setNextRequest(\"Get poll item\")\r", + "}else{\r", + " postman.setNextRequest(\"Update poll\")\r", + "}" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ "" ], "type": "text/javascript" @@ -866,22 +873,21 @@ "method": "GET", "header": [], "url": { - "raw": "{{base-url}}/v1/polls/{{poll-id}}/poll-items", + "raw": "{{base-url}}/v1/poll-items/{{poll-item-id}}", "host": [ "{{base-url}}" ], "path": [ "v1", - "polls", - "{{poll-id}}", - "poll-items" + "poll-items", + "{{poll-item-id}}" ] } }, "response": [] }, { - "name": "Get poll item", + "name": "Update poll", "event": [ { "listen": "test", @@ -893,20 +899,38 @@ ], "type": "text/javascript" } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } } ], "request": { - "method": "GET", + "method": "PUT", "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"name\": \"{{$randomProductName}}\",\r\n \"startDate\": \"1610705932\",\r\n \"endDate\":\"1610705932\",\r\n \"slug\": \"12345\",\r\n \"currentItem\": {{poll-item-id}}\r\n \r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, "url": { - "raw": "{{base-url}}/v1/poll-items/{{poll-item-id}}", + "raw": "{{base-url}}/v1/polls/{{poll-id}}", "host": [ "{{base-url}}" ], "path": [ "v1", - "poll-items", - "{{poll-item-id}}" + "polls", + "{{poll-id}}" ] } }, @@ -919,7 +943,7 @@ "listen": "test", "script": { "exec": [ - "const url = \"localhost:8080/v1/poll-items/\" + pm.environment.get(\"poll-item-id\");\r", + "const url = \"localhost:8080/v1/poll-items/\" + pm.globals.get(\"poll-item-id\");\r", "pm.sendRequest(url, function(error, response){\r", " pm.test(\"Poll item does not exist anymore\", function(){\r", " pm.expect(response).to.have.property('code', 404)\r", @@ -955,13 +979,18 @@ "listen": "test", "script": { "exec": [ - "const url = \"localhost:8080/v1/polls/\" + pm.environment.get(\"poll-id\");\r", + "const url = \"localhost:8080/v1/polls/\" + pm.globals.get(\"poll-id\");\r", "pm.sendRequest(url, function(error, response){\r", " pm.test(\"Poll item does not exist anymore\", function(){\r", " pm.expect(response).to.have.property('code', 404)\r", " pm.expect(response).to.have.property('status', 'Not Found')\r", " })\r", - "})" + "})\r", + "\r", + "pm.globals.unset(\"poll-id\")\r", + "pm.globals.unset(\"poll-item-id\")\r", + "pm.globals.unset(\"poll-items\")\r", + "pm.globals.unset(\"poll-items-counter\")" ], "type": "text/javascript" } @@ -1006,12 +1035,5 @@ ] } } - ], - "variable": [ - { - "id": "004e3f97-e927-4aa2-b46a-5e55a671a4cd", - "key": "base-url", - "value": "https://dev.api.live-poll.de" - } ] } \ No newline at end of file diff --git a/src/main/kotlin/de/livepoll/api/config/SecurityConfig.kt b/src/main/kotlin/de/livepoll/api/config/SecurityConfig.kt index 426cdf85..8ed12a7d 100644 --- a/src/main/kotlin/de/livepoll/api/config/SecurityConfig.kt +++ b/src/main/kotlin/de/livepoll/api/config/SecurityConfig.kt @@ -14,10 +14,6 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter import org.springframework.security.authentication.AuthenticationManager - - - - @Configuration @EnableWebSecurity class SecurityConfig( @@ -31,7 +27,8 @@ class SecurityConfig( .antMatchers("/v1/account/register").permitAll() .antMatchers("/v1/account/confirm").permitAll() .antMatchers("/v1/account/login").permitAll() - // .antMatchers("/admin").hasRole("ADMIN") // TODO: introduce ROLE_ADMIN authority later on + .antMatchers("/v1/websocket/**").permitAll() + //.antMatchers("/admin").hasRole("ADMIN") // TODO: introduce ROLE_ADMIN authority later on .anyRequest().authenticated() .and() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) @@ -59,5 +56,4 @@ class SecurityConfig( "/" ) } - } diff --git a/src/main/kotlin/de/livepoll/api/config/WebSocketConfig.kt b/src/main/kotlin/de/livepoll/api/config/WebSocketConfig.kt index 6c7baffe..4995ddb2 100644 --- a/src/main/kotlin/de/livepoll/api/config/WebSocketConfig.kt +++ b/src/main/kotlin/de/livepoll/api/config/WebSocketConfig.kt @@ -1,5 +1,7 @@ package de.livepoll.api.config +import de.livepoll.api.util.websocket.CustomWebSocketHandshakeHandler +import de.livepoll.api.util.websocket.HttpHandshakeInterceptor import org.springframework.context.annotation.Configuration import org.springframework.messaging.simp.config.MessageBrokerRegistry import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker @@ -9,16 +11,20 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerCo @Configuration @EnableWebSocketMessageBroker -class WebSocketConfig : WebSocketMessageBrokerConfigurer { - +class WebSocketConfig( + private val corsConfig: CorsConfig +) : WebSocketMessageBrokerConfigurer { override fun configureMessageBroker(registry: MessageBrokerRegistry) { - registry.enableSimpleBroker("/websocket-poll/") - registry.setApplicationDestinationPrefixes("/websocket-answer/") + registry.enableSimpleBroker("/v1/websocket/poll") + registry.setApplicationDestinationPrefixes("/v1/websocket/answer") } override fun registerStompEndpoints(registry: StompEndpointRegistry) { - registry.addEndpoint("/websocket-enter-poll") + registry.addEndpoint("/v1/websocket/enter-poll") + .addInterceptors(HttpHandshakeInterceptor()) + .setHandshakeHandler(CustomWebSocketHandshakeHandler()) + .setAllowedOrigins("*") } } \ No newline at end of file diff --git a/src/main/kotlin/de/livepoll/api/controller/WebSocketController.kt b/src/main/kotlin/de/livepoll/api/controller/WebSocketController.kt index f5fbe609..6090006d 100644 --- a/src/main/kotlin/de/livepoll/api/controller/WebSocketController.kt +++ b/src/main/kotlin/de/livepoll/api/controller/WebSocketController.kt @@ -1,5 +1,6 @@ package de.livepoll.api.controller +import de.livepoll.api.service.WebSocketService import org.springframework.messaging.handler.annotation.DestinationVariable import org.springframework.messaging.handler.annotation.MessageMapping import org.springframework.messaging.handler.annotation.Payload @@ -7,10 +8,12 @@ import org.springframework.stereotype.Controller @Controller class WebSocketController( - + private val webSocketService: WebSocketService ) { - @MessageMapping("{pollId}") - fun processAnswer(@DestinationVariable pollId: Int, @Payload answer: String) { - //TODO store answer + @MessageMapping("/{pollItemId}") + fun processAnswer(@DestinationVariable pollItemId: Long, @Payload answer: String) { + println(answer) + println(pollItemId) + webSocketService.saveAnswer(pollItemId, answer) } } \ No newline at end of file diff --git a/src/main/kotlin/de/livepoll/api/entity/db/MultipleChoiceItemAnswer.kt b/src/main/kotlin/de/livepoll/api/entity/db/MultipleChoiceItemAnswer.kt index 191d01f5..84eb02d2 100644 --- a/src/main/kotlin/de/livepoll/api/entity/db/MultipleChoiceItemAnswer.kt +++ b/src/main/kotlin/de/livepoll/api/entity/db/MultipleChoiceItemAnswer.kt @@ -18,12 +18,12 @@ class MultipleChoiceItemAnswer ( @NonNull @ManyToOne @JoinColumn(name = "poll_item_id") - val multipleChoiceItem: MultipleChoiceItem, + var multipleChoiceItem: MultipleChoiceItem, @Column(name = "selection_option") val selectionOption: String, @Column(name = "answer_count") - val answerCount: Int + var answerCount: Int ) diff --git a/src/main/kotlin/de/livepoll/api/entity/db/Poll.kt b/src/main/kotlin/de/livepoll/api/entity/db/Poll.kt index 396377b3..2877d5cc 100644 --- a/src/main/kotlin/de/livepoll/api/entity/db/Poll.kt +++ b/src/main/kotlin/de/livepoll/api/entity/db/Poll.kt @@ -27,7 +27,7 @@ data class Poll( var slug: String, @Column(nullable = true) - var currentItem : Int?, + var currentItem : Long?, @JsonIgnore @OneToMany(mappedBy = "poll", cascade = [CascadeType.ALL]) diff --git a/src/main/kotlin/de/livepoll/api/entity/db/QuizItemAnswer.kt b/src/main/kotlin/de/livepoll/api/entity/db/QuizItemAnswer.kt index 85dc42e0..05713ad6 100644 --- a/src/main/kotlin/de/livepoll/api/entity/db/QuizItemAnswer.kt +++ b/src/main/kotlin/de/livepoll/api/entity/db/QuizItemAnswer.kt @@ -27,6 +27,6 @@ class QuizItemAnswer( val isCorrect: Boolean, @Column(name = "answer_count") - val answerCount: Int + var answerCount: Int ) diff --git a/src/main/kotlin/de/livepoll/api/entity/dto/MultipleChoiceItemParticipantAnswerDtoIn.kt b/src/main/kotlin/de/livepoll/api/entity/dto/MultipleChoiceItemParticipantAnswerDtoIn.kt new file mode 100644 index 00000000..46290eb3 --- /dev/null +++ b/src/main/kotlin/de/livepoll/api/entity/dto/MultipleChoiceItemParticipantAnswerDtoIn.kt @@ -0,0 +1,12 @@ +package de.livepoll.api.entity.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class MultipleChoiceItemParticipantAnswerDtoIn( + @JsonProperty("id") + val id: Long, + @JsonProperty("type") + val type: String, + @JsonProperty("selectionOption") + val selectionOption: String +) \ No newline at end of file diff --git a/src/main/kotlin/de/livepoll/api/entity/dto/OpenTextItemParticipantAnswerDtoIn.kt b/src/main/kotlin/de/livepoll/api/entity/dto/OpenTextItemParticipantAnswerDtoIn.kt new file mode 100644 index 00000000..6f1b6744 --- /dev/null +++ b/src/main/kotlin/de/livepoll/api/entity/dto/OpenTextItemParticipantAnswerDtoIn.kt @@ -0,0 +1,12 @@ +package de.livepoll.api.entity.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class OpenTextItemParticipantAnswerDtoIn( + @JsonProperty("id") + val id: Long, + @JsonProperty("type") + val type: String, + @JsonProperty("answer") + val answer: String +) \ No newline at end of file diff --git a/src/main/kotlin/de/livepoll/api/entity/dto/PollDtoIn.kt b/src/main/kotlin/de/livepoll/api/entity/dto/PollDtoIn.kt index 5d8581c8..9443ed56 100644 --- a/src/main/kotlin/de/livepoll/api/entity/dto/PollDtoIn.kt +++ b/src/main/kotlin/de/livepoll/api/entity/dto/PollDtoIn.kt @@ -7,6 +7,6 @@ data class PollDtoIn( val startDate: Date, val endDate: Date, val slug: String?, - val currentItem: Int? + val currentItem: Long? ) diff --git a/src/main/kotlin/de/livepoll/api/entity/dto/PollDtoOut.kt b/src/main/kotlin/de/livepoll/api/entity/dto/PollDtoOut.kt index 3820be74..1e7e785f 100644 --- a/src/main/kotlin/de/livepoll/api/entity/dto/PollDtoOut.kt +++ b/src/main/kotlin/de/livepoll/api/entity/dto/PollDtoOut.kt @@ -8,5 +8,5 @@ data class PollDtoOut( val startDate: Date, val endDate: Date, val slug: String, - var currentItem: Int? + var currentItem: Long? ) diff --git a/src/main/kotlin/de/livepoll/api/entity/dto/QuizItemParticipantAnswerDtoIn.kt b/src/main/kotlin/de/livepoll/api/entity/dto/QuizItemParticipantAnswerDtoIn.kt new file mode 100644 index 00000000..a86d6477 --- /dev/null +++ b/src/main/kotlin/de/livepoll/api/entity/dto/QuizItemParticipantAnswerDtoIn.kt @@ -0,0 +1,12 @@ +package de.livepoll.api.entity.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class QuizItemParticipantAnswerDtoIn( + @JsonProperty("id") + val id: Long, + @JsonProperty("type") + val type: String, + @JsonProperty("selectionOption") + val selectionOption: String +) \ No newline at end of file diff --git a/src/main/kotlin/de/livepoll/api/service/AccountService.kt b/src/main/kotlin/de/livepoll/api/service/AccountService.kt index ee75cff5..091ad2d1 100644 --- a/src/main/kotlin/de/livepoll/api/service/AccountService.kt +++ b/src/main/kotlin/de/livepoll/api/service/AccountService.kt @@ -24,34 +24,17 @@ import java.util.* import javax.servlet.http.HttpServletRequest @RestController -class AccountService { - - @Autowired - private lateinit var passwordEncoder: PasswordEncoder - - @Autowired - private lateinit var userRepository: UserRepository - - @Autowired - private lateinit var eventPublisher: ApplicationEventPublisher - - @Autowired - private lateinit var verificationTokenRepository: VerificationTokenRepository - - @Autowired - private lateinit var cookieUtil: CookieUtil - - @Autowired - private lateinit var jwtUtil: JwtUtil - - @Autowired - private lateinit var jwtUserDetailsService: JwtUserDetailsService - - @Autowired - private lateinit var cookieCipher: CookieCipher - - @Autowired - private lateinit var blockedTokenRepository: BlockedTokenRepository +class AccountService( + private val passwordEncoder: PasswordEncoder, + private val userRepository: UserRepository, + private val eventPublisher: ApplicationEventPublisher, + private val verificationTokenRepository: VerificationTokenRepository, + private val cookieUtil: CookieUtil, + private val jwtUtil: JwtUtil, + private val jwtUserDetailsService: JwtUserDetailsService, + private val cookieCipher: CookieCipher, + private val blockedTokenRepository: BlockedTokenRepository +) { fun createAccount(user: User): User { return user.apply { @@ -72,20 +55,23 @@ class AccountService { fun confirmAccount(token: String): Boolean { val verificationToken = verificationTokenRepository.findByToken(token) - return if (verificationToken.expiryDate.after(Date())) { + if (verificationToken.expiryDate.after(Date())) { val user = userRepository.findByUsername(verificationToken.username) user?.isAccountEnabled = true verificationTokenRepository.delete(verificationToken) - true - } else false + return true + } + return false } - private fun calculateExpiryDate() = Calendar.getInstance().apply { add(Calendar.MINUTE, 60 * 24) }.time + private fun calculateExpiryDate() = Calendar.getInstance() + .apply { add(Calendar.MINUTE, 60 * 24) } + .time fun login(username: String): ResponseEntity<*> { val user = userRepository.findByUsername(username) ?: userRepository.findByEmail(username) user?.run { - if (user.isAccountEnabled) { + if (isAccountEnabled) { val userDetails = jwtUserDetailsService.loadUserByUsername(username) val responseHeaders = HttpHeaders() responseHeaders.add( @@ -119,5 +105,4 @@ class AccountService { response["message"] = "Logout successful" return ResponseEntity.ok().headers(responseHeaders).body(response) } - } diff --git a/src/main/kotlin/de/livepoll/api/service/JwtUserDetailsService.kt b/src/main/kotlin/de/livepoll/api/service/JwtUserDetailsService.kt index d79e1823..107e6cb3 100644 --- a/src/main/kotlin/de/livepoll/api/service/JwtUserDetailsService.kt +++ b/src/main/kotlin/de/livepoll/api/service/JwtUserDetailsService.kt @@ -21,5 +21,4 @@ class JwtUserDetailsService : UserDetailsService { } return user } - } diff --git a/src/main/kotlin/de/livepoll/api/service/PollItemService.kt b/src/main/kotlin/de/livepoll/api/service/PollItemService.kt index d947f3f6..56b4561a 100644 --- a/src/main/kotlin/de/livepoll/api/service/PollItemService.kt +++ b/src/main/kotlin/de/livepoll/api/service/PollItemService.kt @@ -25,34 +25,21 @@ class PollItemService( pollItemRepository.findById(pollItemId) .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Poll item not found") } .run { - when (this.type) { - + return when (type) { // Multiple Choice - PollItemType.MULTIPLE_CHOICE -> { - return multipleChoiceItemRepository.findById(pollItemId) - .orElseThrow { - ResponseStatusException( - HttpStatus.NOT_FOUND, - "Multiple choice item not found" - ) - } - .run { this.toDtoOut() } - } + PollItemType.MULTIPLE_CHOICE -> multipleChoiceItemRepository.findById(pollItemId) + .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Multiple choice item not found") } + .toDtoOut() - // Open text - PollItemType.OPEN_TEXT -> { - return openTextItemRepository.findById(pollItemId) - .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, ("Open text item not found")) } - .run { this.toDtoOut() } - } + // Open Text + PollItemType.OPEN_TEXT -> openTextItemRepository.findById(pollItemId) + .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, ("Open text item not found")) } + .toDtoOut() // Quiz - PollItemType.QUIZ -> { - return quizItemRepository.findById(pollItemId) - .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Quiz item not found") } - .run { this.toDtoOut() } - } - + PollItemType.QUIZ -> quizItemRepository.findById(pollItemId) + .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Quiz item not found") } + .toDtoOut() } } } @@ -61,7 +48,6 @@ class PollItemService( pollItemRepository.deleteById(itemId) } - //-------------------------------------------- Create -------------------------------------------------------------- fun createMultipleChoiceItem(item: MultipleChoiceItemDtoIn): MultipleChoiceItemDtoOut { diff --git a/src/main/kotlin/de/livepoll/api/service/PollService.kt b/src/main/kotlin/de/livepoll/api/service/PollService.kt index f1779131..b14dc326 100644 --- a/src/main/kotlin/de/livepoll/api/service/PollService.kt +++ b/src/main/kotlin/de/livepoll/api/service/PollService.kt @@ -16,57 +16,56 @@ import java.util.* @Service class PollService( - private val userRepository: UserRepository, - private val pollRepository: PollRepository, - private val pollItemService: PollItemService, - private val webSocketService: WebSocketService + private val userRepository: UserRepository, + private val pollRepository: PollRepository, + private val pollItemService: PollItemService, + private val webSocketService: WebSocketService ) { //--------------------------------------------- Get ---------------------------------------------------------------- fun getPoll(pollId: Long): PollDtoOut { return pollRepository.findById(pollId) - .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Poll not found") } - .run { this.toDtoOut() } + .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Poll not found") } + .run { this.toDtoOut() } } fun getPollItemsForPoll(pollId: Long): List { pollRepository.findById(pollId) - .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Poll not found") } - .run { - return this.pollItems.map { pollItemService.getPollItem(it.id) } - } + .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Poll not found") } + .run { + return this.pollItems.map { pollItemService.getPollItem(it.id) } + } } - //-------------------------------------------- Create -------------------------------------------------------------- fun createPoll(pollDto: PollDtoIn, userId: Long): PollDtoOut { userRepository.findById(userId) - .orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "User not found") - } - .run { - val r = Random() - var slug: String - do { - slug = "" - for (i in 1..6) { - slug += r.nextInt(16) - } - } while (!isSlugUnique(slug)) - val poll = Poll( - 0, - this, - pollDto.name, - pollDto.startDate, - pollDto.endDate, - slug, - null, - emptyList().toMutableList() - ) - return pollRepository.saveAndFlush(poll).toDtoOut() - } + .orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "User not found") + } + .run { + val r = Random() + var slug: String + do { + slug = "" + for (i in 1..6) { + slug += r.nextInt(16) + } + } while (!isSlugUnique(slug)) + val poll = Poll( + 0, + this, + pollDto.name, + pollDto.startDate, + pollDto.endDate, + slug, + null, + emptyList().toMutableList() + ) + return pollRepository.saveAndFlush(poll).toDtoOut() + } } @@ -80,7 +79,6 @@ class PollService( } } - //-------------------------------------------- Update -------------------------------------------------------------- fun updatePoll(pollId: Long, poll: PollDtoIn): PollDtoOut { @@ -95,7 +93,7 @@ class PollService( } if (poll.currentItem != null) { this.currentItem = poll.currentItem - // TODO webSocketService.sendCurrenItem(this.slug, this.currentItem!!) + webSocketService.sendCurrentItem(this.slug, this.currentItem!!) } else { this.currentItem = null } @@ -103,9 +101,5 @@ class PollService( } } - fun isSlugUnique(slug: String): Boolean { - val poll: Poll? = pollRepository.findBySlug(slug) - return poll == null - } - + fun isSlugUnique(slug: String) = pollRepository.findBySlug(slug) == null } diff --git a/src/main/kotlin/de/livepoll/api/service/WebSocketService.kt b/src/main/kotlin/de/livepoll/api/service/WebSocketService.kt index aa0bf229..cd036b80 100644 --- a/src/main/kotlin/de/livepoll/api/service/WebSocketService.kt +++ b/src/main/kotlin/de/livepoll/api/service/WebSocketService.kt @@ -1,16 +1,74 @@ package de.livepoll.api.service + +import com.fasterxml.jackson.databind.ObjectMapper +import de.livepoll.api.entity.db.* +import de.livepoll.api.entity.dto.MultipleChoiceItemParticipantAnswerDtoIn +import de.livepoll.api.entity.dto.OpenTextItemParticipantAnswerDtoIn +import de.livepoll.api.entity.dto.PollItemDtoOut +import de.livepoll.api.entity.dto.QuizItemParticipantAnswerDtoIn +import de.livepoll.api.repository.* +import de.livepoll.api.util.toDbEntity import org.springframework.messaging.simp.SimpMessageSendingOperations +import org.springframework.messaging.simp.user.SimpUserRegistry import org.springframework.stereotype.Controller + @Controller class WebSocketService( - private val messagingTemplate: SimpMessageSendingOperations + private val messagingTemplate: SimpMessageSendingOperations, + private val pollItemService: PollItemService, + private val openTextItemAnswerRepository: OpenTextItemAnswerRepository, + private val simpUserRegistry: SimpUserRegistry, + private val multipleChoiceItemAnswerRepository: MultipleChoiceItemAnswerRepository, + private val quizItemAnswerRepository: QuizItemAnswerRepository, + private val openTextItemRepository: OpenTextItemRepository ) { + fun sendCurrentItem(slug: String, currentItemId: Long) { + val item: PollItemDtoOut = pollItemService.getPollItem(currentItemId); + val url = "/v1/websocket/poll/$slug" + simpUserRegistry.users.forEach { + messagingTemplate.convertAndSendToUser(it.name, url, item) + } + } + + + fun saveAnswer(pollItemId: Long, payload: String) { + println("PAYLOAD: $payload") + val mapper = ObjectMapper() + val type: String = mapper.readValue(payload, Map::class.java)["type"].toString() + when (getPollItemType(type)) { + // Multiple Choice + PollItemType.MULTIPLE_CHOICE -> { + val obj: MultipleChoiceItemParticipantAnswerDtoIn = mapper.readValue(payload, MultipleChoiceItemParticipantAnswerDtoIn::class.java) + val multipleChoiceItemAnswer = multipleChoiceItemAnswerRepository.getOne(obj.id) + multipleChoiceItemAnswer.answerCount++ + multipleChoiceItemAnswerRepository.saveAndFlush(multipleChoiceItemAnswer) + } + + // Open text + PollItemType.OPEN_TEXT -> { + val obj: OpenTextItemParticipantAnswerDtoIn = mapper.readValue(payload, OpenTextItemParticipantAnswerDtoIn::class.java) + val pollItem = openTextItemRepository.getOne(pollItemId) + openTextItemAnswerRepository.saveAndFlush(obj.toDbEntity(pollItem)) + } + + // Quiz + PollItemType.QUIZ -> { + val obj: QuizItemParticipantAnswerDtoIn = mapper.readValue(payload, QuizItemParticipantAnswerDtoIn::class.java) + val quizItemAnswer = quizItemAnswerRepository.getOne(obj.id) + quizItemAnswer.answerCount++ + quizItemAnswerRepository.saveAndFlush(quizItemAnswer) + } + } + } - fun sendCurrenItem(slug: String, currentItemId: Int) { - //val item: PollItem = ?? TODO find current item - val url = "/poll/" + slug - messagingTemplate.convertAndSend(url) //(url, item) + private fun getPollItemType(type: String): PollItemType { + return when (type.toLowerCase()) { + "multiple-choice" -> PollItemType.MULTIPLE_CHOICE + "open-text" -> PollItemType.OPEN_TEXT + "quiz" -> PollItemType.QUIZ + else -> throw Exception("Item type not allowed") + } } } \ No newline at end of file diff --git a/src/main/kotlin/de/livepoll/api/util/CustomModelMapper.kt b/src/main/kotlin/de/livepoll/api/util/CustomModelMapper.kt index 59189adf..a09ba269 100644 --- a/src/main/kotlin/de/livepoll/api/util/CustomModelMapper.kt +++ b/src/main/kotlin/de/livepoll/api/util/CustomModelMapper.kt @@ -50,3 +50,7 @@ fun OpenTextItem.toDtoOut(): OpenTextItemDtoOut { fun OpenTextItemAnswer.toDtoOut(): OpenTextItemAnswerDtoOut { return OpenTextItemAnswerDtoOut(this.id, this.answer) } + +fun OpenTextItemParticipantAnswerDtoIn.toDbEntity(item: OpenTextItem): OpenTextItemAnswer{ + return OpenTextItemAnswer(0, item, this.answer) +} diff --git a/src/main/kotlin/de/livepoll/api/util/websocket/CustomWebSocketHandshakeHandler.kt b/src/main/kotlin/de/livepoll/api/util/websocket/CustomWebSocketHandshakeHandler.kt new file mode 100644 index 00000000..f5f42256 --- /dev/null +++ b/src/main/kotlin/de/livepoll/api/util/websocket/CustomWebSocketHandshakeHandler.kt @@ -0,0 +1,25 @@ +package de.livepoll.api.util.websocket + +import org.springframework.http.server.ServerHttpRequest +import org.springframework.web.socket.WebSocketHandler +import org.springframework.web.socket.server.support.DefaultHandshakeHandler +import java.security.Principal +import java.util.* + + +class CustomWebSocketHandshakeHandler : DefaultHandshakeHandler() { + + private val ATTR_PRINCIPAL = "_principal_" + + override fun determineUser(request: ServerHttpRequest, wsHandler: WebSocketHandler, attributes: MutableMap): Principal? { + val name: String + if (!attributes.containsKey(ATTR_PRINCIPAL)) { + name = UUID.randomUUID().toString() + attributes[ATTR_PRINCIPAL] = name + } else { + name = attributes[ATTR_PRINCIPAL] as String + } + return Principal { name } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/de/livepoll/api/util/websocket/HttpHandshakeInterceptor.kt b/src/main/kotlin/de/livepoll/api/util/websocket/HttpHandshakeInterceptor.kt new file mode 100644 index 00000000..84a970c2 --- /dev/null +++ b/src/main/kotlin/de/livepoll/api/util/websocket/HttpHandshakeInterceptor.kt @@ -0,0 +1,24 @@ +package de.livepoll.api.util.websocket + +import org.springframework.http.server.ServerHttpRequest +import org.springframework.http.server.ServerHttpResponse +import org.springframework.http.server.ServletServerHttpRequest +import org.springframework.web.socket.WebSocketHandler +import org.springframework.web.socket.server.HandshakeInterceptor + + +class HttpHandshakeInterceptor : HandshakeInterceptor { + override fun beforeHandshake(request: ServerHttpRequest, response: ServerHttpResponse, webSocketHandler: WebSocketHandler, attributes: MutableMap): Boolean { + if (request is ServletServerHttpRequest) { + val servletRequest = request as ServletServerHttpRequest + val session = servletRequest.servletRequest.session + attributes["sessionId"] = session.id + } + println("test. ID: " + attributes.getValue("sessionId")) + return true + } + + override fun afterHandshake(p0: ServerHttpRequest, p1: ServerHttpResponse, p2: WebSocketHandler, p3: Exception?) { + + } +} \ No newline at end of file diff --git a/src/main/kotlin/de/livepoll/api/util/websocket/SubscribeListener.kt b/src/main/kotlin/de/livepoll/api/util/websocket/SubscribeListener.kt new file mode 100644 index 00000000..37988f17 --- /dev/null +++ b/src/main/kotlin/de/livepoll/api/util/websocket/SubscribeListener.kt @@ -0,0 +1,36 @@ +package de.livepoll.api.util.websocket + +import de.livepoll.api.repository.PollRepository +import de.livepoll.api.service.PollItemService +import org.springframework.context.ApplicationListener +import org.springframework.http.HttpStatus +import org.springframework.messaging.simp.SimpMessageSendingOperations +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.server.ResponseStatusException +import org.springframework.web.socket.messaging.SessionSubscribeEvent + +@Component +class SubscribeListener( + private val messagingTemplate: SimpMessageSendingOperations, + private val pollRepository: PollRepository, + private val pollItemService: PollItemService +) : ApplicationListener { + + @Transactional + override fun onApplicationEvent(event: SessionSubscribeEvent) { + val slug = event.message.headers["simpDestination"].toString().split("/").last() + val poll = pollRepository.findBySlug(slug) + if (poll == null) { + throw ResponseStatusException(HttpStatus.NOT_FOUND) + } else { + if (poll.currentItem == null) { + throw ResponseStatusException(HttpStatus.CONTINUE) + } else { + val pollItemDto = pollItemService.getPollItem(poll.currentItem!!) + val url = "/v1/websocket/poll/$slug" + messagingTemplate.convertAndSendToUser(event.user!!.name, url, pollItemDto) + } + } + } +} \ No newline at end of file From 753cd9e18b8fd35d2a64bd4961a1f2d4ff1c8de0 Mon Sep 17 00:00:00 2001 From: Dominic Plein <37160523+Ordinateur-Hack@users.noreply.github.com> Date: Mon, 3 May 2021 19:39:39 +0200 Subject: [PATCH 02/25] Testing/init junit5 tests (#79) * Init poll item service unit tests * Add tests for quiz poll items * Add tests for open text poll items Co-authored-by: Niklas-23 <65356992+Niklas-23@users.noreply.github.com> --- .../de/livepoll/api/entity/db/PollItem.kt | 6 +- .../livepoll/api/service/PollItemService.kt | 31 +- .../livepoll/api/LivePollApplicationTests.kt | 14 - .../api/controller/v1/PollItemServiceTests.kt | 333 ++++++++++++++++++ .../kotlin/de/livepoll/api/entity/PollTest.kt | 21 -- 5 files changed, 359 insertions(+), 46 deletions(-) delete mode 100644 src/test/kotlin/de/livepoll/api/LivePollApplicationTests.kt create mode 100644 src/test/kotlin/de/livepoll/api/controller/v1/PollItemServiceTests.kt delete mode 100644 src/test/kotlin/de/livepoll/api/entity/PollTest.kt diff --git a/src/main/kotlin/de/livepoll/api/entity/db/PollItem.kt b/src/main/kotlin/de/livepoll/api/entity/db/PollItem.kt index 6b87cee3..8911dce9 100644 --- a/src/main/kotlin/de/livepoll/api/entity/db/PollItem.kt +++ b/src/main/kotlin/de/livepoll/api/entity/db/PollItem.kt @@ -33,6 +33,8 @@ open class PollItem( open val type: PollItemType ) -enum class PollItemType { - MULTIPLE_CHOICE, OPEN_TEXT, QUIZ +enum class PollItemType(val representation: String) { + MULTIPLE_CHOICE("multiple-choice"), + OPEN_TEXT("open-text"), + QUIZ("quiz") } diff --git a/src/main/kotlin/de/livepoll/api/service/PollItemService.kt b/src/main/kotlin/de/livepoll/api/service/PollItemService.kt index 56b4561a..700954a7 100644 --- a/src/main/kotlin/de/livepoll/api/service/PollItemService.kt +++ b/src/main/kotlin/de/livepoll/api/service/PollItemService.kt @@ -4,20 +4,33 @@ import de.livepoll.api.entity.db.* import de.livepoll.api.entity.dto.* import de.livepoll.api.repository.* import de.livepoll.api.util.toDtoOut +import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import org.springframework.web.server.ResponseStatusException @Service -class PollItemService( - private val pollRepository: PollRepository, - private val pollItemRepository: PollItemRepository, - private val multipleChoiceItemRepository: MultipleChoiceItemRepository, - private val multipleChoiceItemAnswerRepository: MultipleChoiceItemAnswerRepository, - private val openTextItemRepository: OpenTextItemRepository, - private val quizItemRepository: QuizItemRepository, - private val quizItemAnswerRepository: QuizItemAnswerRepository -) { +class PollItemService { + @Autowired + private lateinit var pollRepository: PollRepository + + @Autowired + private lateinit var pollItemRepository: PollItemRepository + + @Autowired + private lateinit var multipleChoiceItemRepository: MultipleChoiceItemRepository + + @Autowired + private lateinit var multipleChoiceItemAnswerRepository: MultipleChoiceItemAnswerRepository + + @Autowired + private lateinit var openTextItemRepository: OpenTextItemRepository + + @Autowired + private lateinit var quizItemRepository: QuizItemRepository + + @Autowired + private lateinit var quizItemAnswerRepository: QuizItemAnswerRepository //--------------------------------------------- Get ---------------------------------------------------------------- diff --git a/src/test/kotlin/de/livepoll/api/LivePollApplicationTests.kt b/src/test/kotlin/de/livepoll/api/LivePollApplicationTests.kt deleted file mode 100644 index 39ab65e7..00000000 --- a/src/test/kotlin/de/livepoll/api/LivePollApplicationTests.kt +++ /dev/null @@ -1,14 +0,0 @@ -package de.livepoll.api - -import org.junit.jupiter.api.Test -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.test.context.ActiveProfiles - -@SpringBootTest -@ActiveProfiles("test") -class LivePollApplicationTests { -// @Test -// fun startTests() { -// -// } -} diff --git a/src/test/kotlin/de/livepoll/api/controller/v1/PollItemServiceTests.kt b/src/test/kotlin/de/livepoll/api/controller/v1/PollItemServiceTests.kt new file mode 100644 index 00000000..049fb4d7 --- /dev/null +++ b/src/test/kotlin/de/livepoll/api/controller/v1/PollItemServiceTests.kt @@ -0,0 +1,333 @@ +package de.livepoll.api.controller.v1 + +import de.livepoll.api.entity.db.* +import de.livepoll.api.entity.dto.* +import de.livepoll.api.repository.* +import de.livepoll.api.service.PollItemService +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.jupiter.api.DisplayName +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.annotation.Bean +import org.springframework.test.context.junit4.SpringRunner +import java.util.* + + +@RunWith(SpringRunner::class) +@DisplayName("Test poll item service") +class PollItemServiceTests { + + @Autowired + private lateinit var pollItemService: PollItemService + + @MockBean + private lateinit var pollItemRepository: PollItemRepository + + @MockBean + private lateinit var pollRepository: PollRepository + + @MockBean + private lateinit var multipleChoiceItemRepository: MultipleChoiceItemRepository + + @MockBean + private lateinit var multipleChoiceItemAnswerRepository: MultipleChoiceItemAnswerRepository + + @MockBean + private lateinit var openTextItemRepository: OpenTextItemRepository + + @MockBean + private lateinit var quizItemRepository: QuizItemRepository + + @MockBean + private lateinit var quizItemAnswerRepository: QuizItemAnswerRepository + + // Needed, since we can't use @Autowired in unit tests + // If you know a solution to this "hack", please fix it or open a GitHub issue + @TestConfiguration + class PollitemServiceBean { + @Bean + fun pollItemService() = PollItemService() + } + + // -------------------------------------- Init mock data ----------------------------------------------------------- + private val mockUser = User( + 0, + "junit-tester", + "junit@live-poll.de", + "abc", + true, + "TESTER", + emptyList() + ) + + private val mockPoll = Poll( + 0, + mockUser, + "Test poll", + GregorianCalendar(2021, 5, 2).time, + GregorianCalendar(2021, 5, 3).time, + "test-poll", + 0, + mutableListOf() + ) + + // Different poll item types + private val testDataMultipleChoice = getTestDataForMultipleChoice() + private val assertDataMultipleChoice = getAssertDataForMultipleChoice() + private val testDataQuiz = getTestDataForQuiz() + private val assertDataQuiz = getAssertDataForQuiz() + private val testDataOpenText = getTestDataForOpenText() + private val assertDataOpenText = getAssertDataForOpenText() + + @Before + fun init() { + // Setup fake function calls + + // Multiple Choice + Mockito.`when`(pollItemRepository.findById(0)).thenReturn(Optional.of(testDataMultipleChoice[0])) + Mockito.`when`(pollItemRepository.findById(1)).thenReturn(Optional.of(testDataMultipleChoice[1])) + Mockito.`when`(multipleChoiceItemRepository.findById(0)).thenReturn(Optional.of(testDataMultipleChoice[0])) + Mockito.`when`(multipleChoiceItemRepository.findById(1)).thenReturn(Optional.of(testDataMultipleChoice[1])) + + // Quiz + Mockito.`when`(pollItemRepository.findById(3)).thenReturn(Optional.of(testDataQuiz[0])) + Mockito.`when`(pollItemRepository.findById(4)).thenReturn(Optional.of(testDataQuiz[1])) + Mockito.`when`(quizItemRepository.findById(3)).thenReturn(Optional.of(testDataQuiz[0])) + Mockito.`when`(quizItemRepository.findById(4)).thenReturn(Optional.of(testDataQuiz[1])) + + // Open text + Mockito.`when`(pollItemRepository.findById(5)).thenReturn(Optional.of(testDataOpenText[0])) + Mockito.`when`(pollItemRepository.findById(6)).thenReturn(Optional.of(testDataOpenText[1])) + Mockito.`when`(openTextItemRepository.findById(5)).thenReturn(Optional.of(testDataOpenText[0])) + Mockito.`when`(openTextItemRepository.findById(6)).thenReturn(Optional.of(testDataOpenText[1])) + } + + + // ----------------------------------------------------------------------------------------------------------------- + // ------------------------------ Test Cases + Mock data per poll item type ---------------------------------------- + // ----------------------------------------------------------------------------------------------------------------- + + + // ----------------------------------------- Multiple Choice ------------------------------------------------------- + private fun getTestDataForMultipleChoice(): List { + // Shell + val multipleChoice1 = MultipleChoiceItem( + 0, + mockPoll, + 0, + "Multiple Choice Question1", + false, + false, + mutableListOf() + ) + val multipleChoice2 = MultipleChoiceItem( + 1, + mockPoll, + 1, + "Multiple Choice Question 2", + false, + false, + mutableListOf() + ) + + // Selection options + val answer11 = MultipleChoiceItemAnswer(0, multipleChoice1, "Option1-1", 0) + val answer12 = MultipleChoiceItemAnswer(1, multipleChoice1, "Option1-2", 0) + val answer13 = MultipleChoiceItemAnswer(2, multipleChoice1, "Option1-3", 0) + val answer14 = MultipleChoiceItemAnswer(3, multipleChoice1, "Option1-4", 0) + multipleChoice1.answers = mutableListOf(answer11, answer12, answer13, answer14) + + val answer21 = MultipleChoiceItemAnswer(4, multipleChoice1, "Option2-1", 0) + val answer22 = MultipleChoiceItemAnswer(5, multipleChoice1, "Option2-2", 0) + multipleChoice2.answers = mutableListOf(answer21, answer22) + + return listOf(multipleChoice1, multipleChoice2) + } + + private fun getAssertDataForMultipleChoice(): List { + // Selection options + val answer11 = MultipleChoiceItemAnswerDtoOut(0, "Option1-1", 0) + val answer12 = MultipleChoiceItemAnswerDtoOut(1, "Option1-2", 0) + val answer13 = MultipleChoiceItemAnswerDtoOut(2, "Option1-3", 0) + val answer14 = MultipleChoiceItemAnswerDtoOut(3, "Option1-4", 0) + val answers1 = listOf(answer11, answer12, answer13, answer14) + + val answer21 = MultipleChoiceItemAnswerDtoOut(4, "Option2-1", 0) + val answer22 = MultipleChoiceItemAnswerDtoOut(5, "Option2-2", 0) + val answers2 = listOf(answer21, answer22) + + // Shell + val multipleChoice1 = MultipleChoiceItemDtoOut( + 0, + mockPoll.id, + "Multiple Choice Question1", + 0, + PollItemType.MULTIPLE_CHOICE.representation, + answers1 + ) + val multipleChoice2 = MultipleChoiceItemDtoOut( + 1, + mockPoll.id, + "Multiple Choice Question 2", + 1, + PollItemType.MULTIPLE_CHOICE.representation, + answers2 + ) + + return listOf(multipleChoice1, multipleChoice2) + } + + @Test + @DisplayName("Get a single multiple choice item") + fun testGetPollItemMultipleChoice() { + val result1 = pollItemService.getPollItem(0) + val expected1 = assertDataMultipleChoice[0] + assertThat(result1).usingRecursiveComparison().isEqualTo(expected1) + + val result2 = pollItemService.getPollItem(1) + val expected2 = assertDataMultipleChoice[1] + assertThat(result2).usingRecursiveComparison().isEqualTo(expected2) + } + + + // --------------------------------------------- Quiz -------------------------------------------------------------- + private fun getTestDataForQuiz(): List { + // Shell + val quiz1 = QuizItem( + 3, + mockPoll, + 0, + "Quiz Question1", + mutableListOf() + ) + + val quiz2 = QuizItem( + 4, + mockPoll, + 1, + "Quiz Question2", + mutableListOf() + ) + + // Selection options + val answer11 = QuizItemAnswer(0, quiz1, "Option 1-1", true, 0) + val answer12 = QuizItemAnswer(1, quiz1, "Option 1-2", false, 0) + val answer13 = QuizItemAnswer(2, quiz1, "Option 1-3", false, 0) + val answer14 = QuizItemAnswer(3, quiz1, "Option 1-4", false, 0) + quiz1.answers = mutableListOf(answer11, answer12, answer13, answer14) + + + val answer21 = QuizItemAnswer(4, quiz1, "Option 2-1", false, 0) + val answer22 = QuizItemAnswer(5, quiz1, "Option 2-2", true, 0) + quiz2.answers = mutableListOf(answer21, answer22) + + return listOf(quiz1, quiz2) + } + + private fun getAssertDataForQuiz(): List { + // Selection options + val answer11 = QuizItemAnswerDtoOut(0, "Option 1-1", true, 0) + val answer12 = QuizItemAnswerDtoOut(1, "Option 1-2", false, 0) + val answer13 = QuizItemAnswerDtoOut(2, "Option 1-3", false, 0) + val answer14 = QuizItemAnswerDtoOut(3, "Option 1-4", false, 0) + val answers1 = listOf(answer11, answer12, answer13, answer14) + + val answer21 = QuizItemAnswerDtoOut(4, "Option 2-1", false, 0) + val answer22 = QuizItemAnswerDtoOut(5, "Option 2-2", true, 0) + val answers2 = listOf(answer21, answer22) + + // Shell + val quiz1 = QuizItemDtoOut( + 3, + mockPoll.id, + "Quiz Question1", + 0, + PollItemType.QUIZ.representation, + answers1 + ) + val quiz2 = QuizItemDtoOut( + 4, + mockPoll.id, + "Quiz Question2", + 1, + PollItemType.QUIZ.representation, + answers2 + ) + + return listOf(quiz1, quiz2) + } + + @Test + @DisplayName("Get a single quiz item") + fun testGetPollItemQuiz() { + val result1 = pollItemService.getPollItem(3) + val expected1 = assertDataQuiz[0] + assertThat(result1).usingRecursiveComparison().isEqualTo(expected1) + + val result2 = pollItemService.getPollItem(4) + val expected2 = assertDataQuiz[1] + assertThat(result2).usingRecursiveComparison().isEqualTo(expected2) + } + + + // ----------------------------------------- Open text ------------------------------------------------------------- + private fun getTestDataForOpenText(): List { + val openText1 = OpenTextItem( + 5, + mockPoll, + "Open text question1", + 0, + mutableListOf() + ) + + val openText2 = OpenTextItem( + 6, + mockPoll, + "Open text question2", + 1, + mutableListOf() + ) + + return listOf(openText1, openText2) + } + + private fun getAssertDataForOpenText(): List { + val openText1 = OpenTextItemDtoOut( + 5, + mockPoll.id, + "Open text question1", + 0, + PollItemType.OPEN_TEXT.representation, + emptyList() + ) + + val openText2 = OpenTextItemDtoOut( + 6, + mockPoll.id, + "Open text question2", + 1, + PollItemType.OPEN_TEXT.representation, + emptyList() + ) + + return listOf(openText1, openText2) + } + + @Test + @DisplayName("Get a single open text item") + fun testGetPollOpenText() { + val result1 = pollItemService.getPollItem(5) + val expected1 = assertDataOpenText[0] + assertThat(result1).usingRecursiveComparison().isEqualTo(expected1) + + val result2 = pollItemService.getPollItem(6) + val expected2 = assertDataOpenText[1] + assertThat(result2).usingRecursiveComparison().isEqualTo(expected2) + } + +} diff --git a/src/test/kotlin/de/livepoll/api/entity/PollTest.kt b/src/test/kotlin/de/livepoll/api/entity/PollTest.kt deleted file mode 100644 index 9ae2ea55..00000000 --- a/src/test/kotlin/de/livepoll/api/entity/PollTest.kt +++ /dev/null @@ -1,21 +0,0 @@ -package de.livepoll.api.entity - -import de.livepoll.api.repository.PollRepository -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Test -import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager -import org.springframework.boot.test.context.SpringBootTest - -@SpringBootTest -class PollTest( - val entityManager: TestEntityManager, - val pollRepository: PollRepository -) { -// @Test -// @Throws -// fun testFind() { -// val poll = pollRepository.getOne(1) -// assertNotNull(poll) -// } - -} \ No newline at end of file From 6b0eec846deb694f6229857afd2a59b23767daa0 Mon Sep 17 00:00:00 2001 From: Dominic Plein <37160523+Ordinateur-Hack@users.noreply.github.com> Date: Mon, 3 May 2021 23:57:20 +0200 Subject: [PATCH 03/25] Implement suggestions from code inspection (#81) Minor adjustments / code cleanup --- SECURITY.md | 2 +- env/README.md | 2 +- .../de/livepoll/api/service/AccountService.kt | 1 - .../de/livepoll/api/service/WebSocketService.kt | 14 +++++++++----- src/main/kotlin/de/livepoll/api/util/JwtUtil.kt | 4 ++-- .../websocket/CustomWebSocketHandshakeHandler.kt | 5 ++--- .../api/util/websocket/HttpHandshakeInterceptor.kt | 5 ++--- .../api/controller/v1/PollItemServiceTests.kt | 12 ++++++------ .../de/livepoll/api/cucumber/RunCucumberTest.kt | 3 +-- .../stepdefinitions/PollStepDefinitions.kt | 11 ++++++----- .../stepdefinitions/UserStepDefinitions.kt | 3 ++- 11 files changed, 32 insertions(+), 30 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index fa1fb67f..56edd44c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,7 @@ That's why we rely on you, the open source contributors, to inform us about actu Please follow the guideline below to get in touch with us, even if you're not sure, if your issue is regarding the data security. ## Reporting a Vulnerability -**Please do not open GitHub issues for security vulnerabilities, cause they are publicly accessible!!!** +**Please do not open GitHub issues for security vulnerabilities since they are publicly accessible!!!** Instead, contact us per mail. We guarantee a response within two workdays and a security patch as fast as possible. diff --git a/env/README.md b/env/README.md index 0cb597e8..fd1d0a37 100644 --- a/env/README.md +++ b/env/README.md @@ -6,7 +6,7 @@ The following sections contain instructions about the selected services and inst MySQL is a relational database. ### Setup -Compose Generator will ask you for the name of a dedicated database and the name of a dedicated user for your application. This database and user will be created on the first startup of the database container. Furthermore the cli automatically generates database user password for you, so you don't need to specify them yourself. +Compose Generator will ask you for the name of a dedicated database and the name of a dedicated user for your application. This database and user will be created on the first startup of the database container. Furthermore, the cli automatically generates a database user password for you, so you don't need to specify it yourself. ## PhpMyAdmin *To be extended ...* diff --git a/src/main/kotlin/de/livepoll/api/service/AccountService.kt b/src/main/kotlin/de/livepoll/api/service/AccountService.kt index 091ad2d1..5d0c6377 100644 --- a/src/main/kotlin/de/livepoll/api/service/AccountService.kt +++ b/src/main/kotlin/de/livepoll/api/service/AccountService.kt @@ -12,7 +12,6 @@ import de.livepoll.api.util.JwtUtil import de.livepoll.api.util.OnCreateAccountEvent import de.livepoll.api.util.jwtCookie.CookieCipher import de.livepoll.api.util.jwtCookie.CookieUtil -import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.ApplicationEventPublisher import org.springframework.http.HttpHeaders import org.springframework.http.ResponseEntity diff --git a/src/main/kotlin/de/livepoll/api/service/WebSocketService.kt b/src/main/kotlin/de/livepoll/api/service/WebSocketService.kt index cd036b80..352146f5 100644 --- a/src/main/kotlin/de/livepoll/api/service/WebSocketService.kt +++ b/src/main/kotlin/de/livepoll/api/service/WebSocketService.kt @@ -2,12 +2,15 @@ package de.livepoll.api.service import com.fasterxml.jackson.databind.ObjectMapper -import de.livepoll.api.entity.db.* +import de.livepoll.api.entity.db.PollItemType import de.livepoll.api.entity.dto.MultipleChoiceItemParticipantAnswerDtoIn import de.livepoll.api.entity.dto.OpenTextItemParticipantAnswerDtoIn import de.livepoll.api.entity.dto.PollItemDtoOut import de.livepoll.api.entity.dto.QuizItemParticipantAnswerDtoIn -import de.livepoll.api.repository.* +import de.livepoll.api.repository.MultipleChoiceItemAnswerRepository +import de.livepoll.api.repository.OpenTextItemAnswerRepository +import de.livepoll.api.repository.OpenTextItemRepository +import de.livepoll.api.repository.QuizItemAnswerRepository import de.livepoll.api.util.toDbEntity import org.springframework.messaging.simp.SimpMessageSendingOperations import org.springframework.messaging.simp.user.SimpUserRegistry @@ -24,15 +27,15 @@ class WebSocketService( private val quizItemAnswerRepository: QuizItemAnswerRepository, private val openTextItemRepository: OpenTextItemRepository ) { + fun sendCurrentItem(slug: String, currentItemId: Long) { - val item: PollItemDtoOut = pollItemService.getPollItem(currentItemId); + val item: PollItemDtoOut = pollItemService.getPollItem(currentItemId) val url = "/v1/websocket/poll/$slug" simpUserRegistry.users.forEach { messagingTemplate.convertAndSendToUser(it.name, url, item) } } - fun saveAnswer(pollItemId: Long, payload: String) { println("PAYLOAD: $payload") val mapper = ObjectMapper() @@ -71,4 +74,5 @@ class WebSocketService( else -> throw Exception("Item type not allowed") } } -} \ No newline at end of file + +} diff --git a/src/main/kotlin/de/livepoll/api/util/JwtUtil.kt b/src/main/kotlin/de/livepoll/api/util/JwtUtil.kt index b703b7f0..631380dd 100644 --- a/src/main/kotlin/de/livepoll/api/util/JwtUtil.kt +++ b/src/main/kotlin/de/livepoll/api/util/JwtUtil.kt @@ -45,8 +45,8 @@ class JwtUtil( private fun isTokenBlocked(token: String?): Boolean { blockedTokenRepository.findByToken(token)?.run { - return true; + return true } - return false; + return false } } \ No newline at end of file diff --git a/src/main/kotlin/de/livepoll/api/util/websocket/CustomWebSocketHandshakeHandler.kt b/src/main/kotlin/de/livepoll/api/util/websocket/CustomWebSocketHandshakeHandler.kt index f5f42256..0120d44f 100644 --- a/src/main/kotlin/de/livepoll/api/util/websocket/CustomWebSocketHandshakeHandler.kt +++ b/src/main/kotlin/de/livepoll/api/util/websocket/CustomWebSocketHandshakeHandler.kt @@ -6,12 +6,11 @@ import org.springframework.web.socket.server.support.DefaultHandshakeHandler import java.security.Principal import java.util.* +private const val ATTR_PRINCIPAL = "_principal_" class CustomWebSocketHandshakeHandler : DefaultHandshakeHandler() { - private val ATTR_PRINCIPAL = "_principal_" - - override fun determineUser(request: ServerHttpRequest, wsHandler: WebSocketHandler, attributes: MutableMap): Principal? { + override fun determineUser(request: ServerHttpRequest, wsHandler: WebSocketHandler, attributes: MutableMap): Principal { val name: String if (!attributes.containsKey(ATTR_PRINCIPAL)) { name = UUID.randomUUID().toString() diff --git a/src/main/kotlin/de/livepoll/api/util/websocket/HttpHandshakeInterceptor.kt b/src/main/kotlin/de/livepoll/api/util/websocket/HttpHandshakeInterceptor.kt index 84a970c2..d33fb047 100644 --- a/src/main/kotlin/de/livepoll/api/util/websocket/HttpHandshakeInterceptor.kt +++ b/src/main/kotlin/de/livepoll/api/util/websocket/HttpHandshakeInterceptor.kt @@ -10,11 +10,10 @@ import org.springframework.web.socket.server.HandshakeInterceptor class HttpHandshakeInterceptor : HandshakeInterceptor { override fun beforeHandshake(request: ServerHttpRequest, response: ServerHttpResponse, webSocketHandler: WebSocketHandler, attributes: MutableMap): Boolean { if (request is ServletServerHttpRequest) { - val servletRequest = request as ServletServerHttpRequest - val session = servletRequest.servletRequest.session + val session = request.servletRequest.session attributes["sessionId"] = session.id } - println("test. ID: " + attributes.getValue("sessionId")) +// println("test. ID: " + attributes.getValue("sessionId")) return true } diff --git a/src/test/kotlin/de/livepoll/api/controller/v1/PollItemServiceTests.kt b/src/test/kotlin/de/livepoll/api/controller/v1/PollItemServiceTests.kt index 049fb4d7..93ab799c 100644 --- a/src/test/kotlin/de/livepoll/api/controller/v1/PollItemServiceTests.kt +++ b/src/test/kotlin/de/livepoll/api/controller/v1/PollItemServiceTests.kt @@ -121,18 +121,18 @@ class PollItemServiceTests { mockPoll, 0, "Multiple Choice Question1", - false, - false, - mutableListOf() + allowMultipleAnswers = false, + allowBlankField = false, + answers = mutableListOf() ) val multipleChoice2 = MultipleChoiceItem( 1, mockPoll, 1, "Multiple Choice Question 2", - false, - false, - mutableListOf() + allowMultipleAnswers = false, + allowBlankField = false, + answers = mutableListOf() ) // Selection options diff --git a/src/test/kotlin/de/livepoll/api/cucumber/RunCucumberTest.kt b/src/test/kotlin/de/livepoll/api/cucumber/RunCucumberTest.kt index ec046569..bdb0d1b1 100644 --- a/src/test/kotlin/de/livepoll/api/cucumber/RunCucumberTest.kt +++ b/src/test/kotlin/de/livepoll/api/cucumber/RunCucumberTest.kt @@ -9,5 +9,4 @@ import org.junit.runner.RunWith features = ["src/test/resources/features/"], plugin = ["pretty", "html:target/cucumber-report.html"] ) -class RunCucumberTest { -} \ No newline at end of file +class RunCucumberTest \ No newline at end of file diff --git a/src/test/kotlin/de/livepoll/api/cucumber/stepdefinitions/PollStepDefinitions.kt b/src/test/kotlin/de/livepoll/api/cucumber/stepdefinitions/PollStepDefinitions.kt index ddf5c5d3..fabce97b 100644 --- a/src/test/kotlin/de/livepoll/api/cucumber/stepdefinitions/PollStepDefinitions.kt +++ b/src/test/kotlin/de/livepoll/api/cucumber/stepdefinitions/PollStepDefinitions.kt @@ -45,9 +45,9 @@ class PollStepDefinitions(userRepository: UserRepository, val pollRepository: Po // make request val responseEntity: ResponseEntity = restTemplate.exchange( - url, - HttpMethod.POST, - requestEntity + url, + HttpMethod.POST, + requestEntity ) assertThat(responseEntity.statusCode).isEqualTo(HttpStatus.CREATED) @@ -61,7 +61,8 @@ class PollStepDefinitions(userRepository: UserRepository, val pollRepository: Po @And("I retrieve my polls") fun retrieveMyPolls() { val url = "${SERVER_URL}:$port$POLLS_ENDPOINT" - val pollResponseEntity = makeGetRequestWithSessionCookie>(url, SessionCookieUtil.sessionCookie) + val pollResponseEntity = + makeGetRequestWithSessionCookie>(url, SessionCookieUtil.sessionCookie) assertThat(pollResponseEntity.statusCode).isEqualTo(HttpStatus.OK) assertThat(pollResponseEntity.body).isNotNull for (poll in pollResponseEntity.body!!) { @@ -76,7 +77,7 @@ class PollStepDefinitions(userRepository: UserRepository, val pollRepository: Po @And("I get back a poll named {string}") fun getBackNamedPoll(pollName: String) { - assertThat(myPolls.get(0).name).isEqualTo(pollName) + assertThat(myPolls[0].name).isEqualTo(pollName) } } diff --git a/src/test/kotlin/de/livepoll/api/cucumber/stepdefinitions/UserStepDefinitions.kt b/src/test/kotlin/de/livepoll/api/cucumber/stepdefinitions/UserStepDefinitions.kt index 1cbfc614..1f32f1ee 100644 --- a/src/test/kotlin/de/livepoll/api/cucumber/stepdefinitions/UserStepDefinitions.kt +++ b/src/test/kotlin/de/livepoll/api/cucumber/stepdefinitions/UserStepDefinitions.kt @@ -11,10 +11,11 @@ import org.springframework.http.* import org.springframework.web.client.RestClientResponseException import org.springframework.web.client.exchange +private const val LOGOUT_ENDPOINT = "/v0/authenticate/logout" + class UserStepDefinitions(userRepository: UserRepository) : CucumberIntegrationTestContext(userRepository) { private val USER_ENDPOINT = "/v0/users/${testUser.id}" private val USER_ENDPOINT_ANOTHER = "/v0/users/${testUser.id + 1}" - private val LOGOUT_ENDPOINT = "/v0/authenticate/logout" lateinit var status: HttpStatus var alreadyConfirmed = false From 3a02ddf960a8868e35e5e781b60a3d61dd6fc0bb Mon Sep 17 00:00:00 2001 From: Dominic Plein <37160523+Ordinateur-Hack@users.noreply.github.com> Date: Tue, 4 May 2021 00:01:39 +0200 Subject: [PATCH 04/25] Feature/quiz item correct option (#80) * Take first answer as correct one (quiz item) * Adjust Postman collection for new quiz item body Also added "Create open text item" to the Postman collection Co-authored-by: Marc Auberer --- postman/Livepoll.postman_collection.json | 53 +++++++++++++++++-- .../api/entity/dto/OpenTextItemDtoIn.kt | 2 +- .../livepoll/api/entity/dto/QuizItemDtoIn.kt | 9 +--- .../livepoll/api/service/PollItemService.kt | 8 ++- 4 files changed, 59 insertions(+), 13 deletions(-) diff --git a/postman/Livepoll.postman_collection.json b/postman/Livepoll.postman_collection.json index b4349737..84387f34 100644 --- a/postman/Livepoll.postman_collection.json +++ b/postman/Livepoll.postman_collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "a3857427-e2b1-4190-90c9-664da2489dcd", + "_postman_id": "4348fa9f-7a6d-4190-b745-470d6a86d0ce", "name": "Livepoll", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, @@ -438,7 +438,7 @@ "response": [] }, { - "name": "Create Quiz Item", + "name": "Create quiz item", "event": [ { "listen": "test", @@ -457,7 +457,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-quiz-question\",\r\n \"position\": 1,\r\n \"answers\": [ { \"answer\" : \"postman-quiz-answer-1\", \"isCorrect\" : true}, { \"answer\" : \"postman-quiz-answer-2\", \"isCorrect\" : false}]\r\n}", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"position\": 1,\r\n \"question\": \"postman-quiz-question\",\r\n \"answers\": [ \"correct option\", \"wrong option\", \"wrong option\"]\r\n}", "options": { "raw": { "language": "json" @@ -478,6 +478,47 @@ }, "response": [] }, + { + "name": "Create open text item", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {\r", + " pm.response.to.have.status(200);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"position\": 1,\r\n \"question\": \"postman-open-text-question\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base-url}}/v1/poll-items/open-text", + "host": [ + "{{base-url}}" + ], + "path": [ + "v1", + "poll-items", + "open-text" + ] + } + }, + "response": [] + }, { "name": "Delete poll item", "event": [ @@ -1035,5 +1076,11 @@ ] } } + ], + "variable": [ + { + "key": "base-url", + "value": "https://localhost:8080" + } ] } \ No newline at end of file diff --git a/src/main/kotlin/de/livepoll/api/entity/dto/OpenTextItemDtoIn.kt b/src/main/kotlin/de/livepoll/api/entity/dto/OpenTextItemDtoIn.kt index c2185a49..5b9d956a 100644 --- a/src/main/kotlin/de/livepoll/api/entity/dto/OpenTextItemDtoIn.kt +++ b/src/main/kotlin/de/livepoll/api/entity/dto/OpenTextItemDtoIn.kt @@ -2,6 +2,6 @@ package de.livepoll.api.entity.dto data class OpenTextItemDtoIn( val pollId: Long, + val position: Int, val question: String, - val position: Int ) diff --git a/src/main/kotlin/de/livepoll/api/entity/dto/QuizItemDtoIn.kt b/src/main/kotlin/de/livepoll/api/entity/dto/QuizItemDtoIn.kt index d783a55c..79f8f241 100644 --- a/src/main/kotlin/de/livepoll/api/entity/dto/QuizItemDtoIn.kt +++ b/src/main/kotlin/de/livepoll/api/entity/dto/QuizItemDtoIn.kt @@ -2,12 +2,7 @@ package de.livepoll.api.entity.dto class QuizItemDtoIn( var pollId: Long, - val question: String, val position: Int, - val answers: List + val question: String, + val answers: List ) - -class QuizItemAnswerDtoIn( - val answer: String, - val isCorrect: Boolean -) \ No newline at end of file diff --git a/src/main/kotlin/de/livepoll/api/service/PollItemService.kt b/src/main/kotlin/de/livepoll/api/service/PollItemService.kt index 700954a7..429e07e2 100644 --- a/src/main/kotlin/de/livepoll/api/service/PollItemService.kt +++ b/src/main/kotlin/de/livepoll/api/service/PollItemService.kt @@ -32,6 +32,7 @@ class PollItemService { @Autowired private lateinit var quizItemAnswerRepository: QuizItemAnswerRepository + //--------------------------------------------- Get ---------------------------------------------------------------- fun getPollItem(pollItemId: Long): PollItemDtoOut { @@ -61,6 +62,7 @@ class PollItemService { pollItemRepository.deleteById(itemId) } + //-------------------------------------------- Create -------------------------------------------------------------- fun createMultipleChoiceItem(item: MultipleChoiceItemDtoIn): MultipleChoiceItemDtoOut { @@ -107,8 +109,10 @@ class PollItemService { mutableListOf() ) // Quiz item answers - quizItem.answers = - item.answers.map { QuizItemAnswer(0, quizItem, it.answer, it.isCorrect, 0) }.toMutableList() + quizItem.answers = item.answers.mapIndexed { index, element -> + QuizItemAnswer(0, quizItem, element, index == 0, 0) + }.toMutableList() + quizItemRepository.saveAndFlush(quizItem) quizItem.answers.forEach { quizItemAnswerRepository.saveAndFlush(it) } return quizItem.toDtoOut() From d1e28cd280ebfbdfd4a116960d07247ad0b33eb7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 May 2021 07:42:46 +0200 Subject: [PATCH 05/25] Bump jacoco-maven-plugin from 0.8.6 to 0.8.7 (#82) Bumps [jacoco-maven-plugin](https://github.com/jacoco/jacoco) from 0.8.6 to 0.8.7. - [Release notes](https://github.com/jacoco/jacoco/releases) - [Commits](https://github.com/jacoco/jacoco/compare/v0.8.6...v0.8.7) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 0ca81923..584e6013 100644 --- a/pom.xml +++ b/pom.xml @@ -183,7 +183,7 @@ org.jacoco jacoco-maven-plugin - 0.8.6 + 0.8.7 From ca44b46a51ad4d76fe75769924a7dadb8f7d777f Mon Sep 17 00:00:00 2001 From: Dominic Plein <37160523+Ordinateur-Hack@users.noreply.github.com> Date: Fri, 7 May 2021 14:16:37 +0200 Subject: [PATCH 06/25] Feature/unit test (#83) * Fix maven test * Cucumber fix * Authorization added so that all tests pass * Cucumber pipeline fix Co-authored-by: Niklas-23 <65356992+Niklas-23@users.noreply.github.com> Co-authored-by: Marc Auberer --- .github/workflows/ci-with-docker-develop.yml | 5 ++- .github/workflows/ci-with-docker.yml | 5 ++- .github/workflows/ci.yml | 31 ++++++++-------- README.md | 1 + pom.xml | 10 ++++-- postman/Livepoll.dev.postman_environment.json | 14 ++++++++ .../api/controller/AccountController.kt | 4 +-- .../livepoll/api/controller/PollController.kt | 6 ++++ .../api/controller/PollItemController.kt | 10 ++++-- .../de/livepoll/api/service/AccountService.kt | 25 ++++++++++--- ...ServiceTests.kt => PollItemServiceTest.kt} | 15 +++++++- ...tContext.kt => CucumberIntegrationTest.kt} | 35 +++++++++++++------ .../livepoll/api/cucumber/RunCucumberTest.kt | 3 +- .../stepdefinitions/PollStepDefinitions.kt | 25 +++++++------ .../stepdefinitions/UserStepDefinitions.kt | 34 +++++++++++++----- .../resources/application-test.properties | 11 ++---- 16 files changed, 159 insertions(+), 75 deletions(-) create mode 100644 postman/Livepoll.dev.postman_environment.json rename src/test/kotlin/de/livepoll/api/controller/v1/{PollItemServiceTests.kt => PollItemServiceTest.kt} (95%) rename src/test/kotlin/de/livepoll/api/cucumber/{CucumberIntegrationTestContext.kt => CucumberIntegrationTest.kt} (79%) diff --git a/.github/workflows/ci-with-docker-develop.yml b/.github/workflows/ci-with-docker-develop.yml index 22ac3b54..678ea094 100644 --- a/.github/workflows/ci-with-docker-develop.yml +++ b/.github/workflows/ci-with-docker-develop.yml @@ -30,11 +30,10 @@ jobs: LIVE_POLL_MAIL_PASSWORD: ${{ secrets.API_LIVE_POLL_MAIL_PASSWORD }} LIVE_POLL_JWT_AUTH_COOKIE_NAME: ${{ secrets.API_LIVE_POLL_JWT_AUTH_COOKIE_NAME }} LIVE_POLL_JWT_COOKIE_KEY_VALUE: ${{ secrets.API_LIVE_POLL_JWT_COOKIE_KEY_VALUE }} + LIVE_POLL_JWT_SECRET: ${{ secrets.API_LIVE_POLL_JWT_SECRET }} LIVE_POLL_HTTPS_ENABLED: ${{ secrets.API_LIVE_POLL_HTTPS_ENABLED }} LIVE_POLL_HTTPS_CERT_PASSWORD: ${{ secrets.API_LIVE_POLL_HTTPS_CERT_PASSWORD }} - LIVE_POLL_H2_URL: ${{ secrets.API_LIVE_POLL_H2_URL }} - TEST_USER_NAME: ${{ secrets.API_TEST_USER_NAME }} - TEST_USER_PASSWORD: ${{ secrets.API_TEST_USER_PASSWORD }} + CUCUMBER_PUBLISH_TOKEN: ${{ secrets.CUCUMBER_PUBLISH_TOKEN }} - name: CodeCov report deploy run: bash <(curl -s https://codecov.io/bash) - name: Prepare environment diff --git a/.github/workflows/ci-with-docker.yml b/.github/workflows/ci-with-docker.yml index 7bd63ed8..1b66f3f1 100644 --- a/.github/workflows/ci-with-docker.yml +++ b/.github/workflows/ci-with-docker.yml @@ -32,11 +32,10 @@ jobs: LIVE_POLL_MAIL_PASSWORD: ${{ secrets.API_LIVE_POLL_MAIL_PASSWORD }} LIVE_POLL_JWT_AUTH_COOKIE_NAME: ${{ secrets.API_LIVE_POLL_JWT_AUTH_COOKIE_NAME }} LIVE_POLL_JWT_COOKIE_KEY_VALUE: ${{ secrets.API_LIVE_POLL_JWT_COOKIE_KEY_VALUE }} + LIVE_POLL_JWT_SECRET: ${{ secrets.API_LIVE_POLL_JWT_SECRET }} LIVE_POLL_HTTPS_ENABLED: ${{ secrets.API_LIVE_POLL_HTTPS_ENABLED }} LIVE_POLL_HTTPS_CERT_PASSWORD: ${{ secrets.API_LIVE_POLL_HTTPS_CERT_PASSWORD }} - LIVE_POLL_H2_URL: ${{ secrets.API_LIVE_POLL_H2_URL }} - TEST_USER_NAME: ${{ secrets.API_TEST_USER_NAME }} - TEST_USER_PASSWORD: ${{ secrets.API_TEST_USER_PASSWORD }} + CUCUMBER_PUBLISH_TOKEN: ${{ secrets.CUCUMBER_PUBLISH_TOKEN }} - name: CodeCov report deploy run: bash <(curl -s https://codecov.io/bash) - name: Prepare environment diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b5dedbd..c1ee8a71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,19 +22,18 @@ jobs: - name: Build and test project with Maven run: mvn -B package --file pom.xml --batch-mode --update-snapshots clean verify env: - LIVE_POLL_MYSQL_URL: ${{ secrets.API_LIVE_POLL_MYSQL_URL }} - LIVE_POLL_MYSQL_USER: ${{ secrets.API_LIVE_POLL_MYSQL_USER }} - LIVE_POLL_MYSQL_PASSWORD: ${{ secrets.API_LIVE_POLL_MYSQL_PASSWORD }} - LIVE_POLL_DEV_URL: ${{ secrets.API_LIVE_POLL_DEV_URL }} - LIVE_POLL_SERVER_URL: ${{ secrets.API_LIVE_POLL_SERVER_URL }} - LIVE_POLL_MAIL_HOST: ${{ secrets.API_LIVE_POLL_MAIL_HOST }} - LIVE_POLL_MAIL_PORT: ${{ secrets.API_LIVE_POLL_MAIL_PORT }} - LIVE_POLL_MAIL_USERNAME: ${{ secrets.API_LIVE_POLL_MAIL_USERNAME }} - LIVE_POLL_MAIL_PASSWORD: ${{ secrets.API_LIVE_POLL_MAIL_PASSWORD }} - LIVE_POLL_JWT_AUTH_COOKIE_NAME: ${{ secrets.API_LIVE_POLL_JWT_AUTH_COOKIE_NAME }} - LIVE_POLL_JWT_COOKIE_KEY_VALUE: ${{ secrets.API_LIVE_POLL_JWT_COOKIE_KEY_VALUE }} - LIVE_POLL_HTTPS_ENABLED: ${{ secrets.API_LIVE_POLL_HTTPS_ENABLED }} - LIVE_POLL_HTTPS_CERT_PASSWORD: ${{ secrets.API_LIVE_POLL_HTTPS_CERT_PASSWORD }} - LIVE_POLL_H2_URL: ${{ secrets.API_LIVE_POLL_H2_URL }} - TEST_USER_NAME: ${{ secrets.API_TEST_USER_NAME }} - TEST_USER_PASSWORD: ${{ secrets.API_TEST_USER_PASSWORD }} \ No newline at end of file + LIVE_POLL_MYSQL_URL: ${{ secrets.API_LIVE_POLL_MYSQL_URL }} + LIVE_POLL_MYSQL_USER: ${{ secrets.API_LIVE_POLL_MYSQL_USER }} + LIVE_POLL_MYSQL_PASSWORD: ${{ secrets.API_LIVE_POLL_MYSQL_PASSWORD }} + LIVE_POLL_DEV_URL: ${{ secrets.API_LIVE_POLL_DEV_URL }} + LIVE_POLL_SERVER_URL: ${{ secrets.API_LIVE_POLL_SERVER_URL }} + LIVE_POLL_MAIL_HOST: ${{ secrets.API_LIVE_POLL_MAIL_HOST }} + LIVE_POLL_MAIL_PORT: ${{ secrets.API_LIVE_POLL_MAIL_PORT }} + LIVE_POLL_MAIL_USERNAME: ${{ secrets.API_LIVE_POLL_MAIL_USERNAME }} + LIVE_POLL_MAIL_PASSWORD: ${{ secrets.API_LIVE_POLL_MAIL_PASSWORD }} + LIVE_POLL_JWT_AUTH_COOKIE_NAME: ${{ secrets.API_LIVE_POLL_JWT_AUTH_COOKIE_NAME }} + LIVE_POLL_JWT_COOKIE_KEY_VALUE: ${{ secrets.API_LIVE_POLL_JWT_COOKIE_KEY_VALUE }} + LIVE_POLL_JWT_SECRET: ${{ secrets.API_LIVE_POLL_JWT_SECRET }} + LIVE_POLL_HTTPS_ENABLED: ${{ secrets.API_LIVE_POLL_HTTPS_ENABLED }} + LIVE_POLL_HTTPS_CERT_PASSWORD: ${{ secrets.API_LIVE_POLL_HTTPS_CERT_PASSWORD }} + CUCUMBER_PUBLISH_TOKEN: ${{ secrets.CUCUMBER_PUBLISH_TOKEN }} \ No newline at end of file diff --git a/README.md b/README.md index 4699d788..2d7922b1 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ + diff --git a/pom.xml b/pom.xml index 584e6013..c6ad6e21 100644 --- a/pom.xml +++ b/pom.xml @@ -52,6 +52,7 @@ com.h2database h2 + test mysql @@ -62,10 +63,15 @@ org.springframework.boot spring-boot-starter-test test + + + org.junit.vintage + junit-vintage-engine + test - org.junit.vintage - junit-vintage-engine + org.hamcrest + hamcrest-core diff --git a/postman/Livepoll.dev.postman_environment.json b/postman/Livepoll.dev.postman_environment.json new file mode 100644 index 00000000..a7c46892 --- /dev/null +++ b/postman/Livepoll.dev.postman_environment.json @@ -0,0 +1,14 @@ +{ + "id": "e6bc2f3d-b160-4f6f-9d7a-04b03380ae32", + "name": "Livepoll Dev", + "values": [ + { + "key": "base-url", + "value": "http://localhost:8080", + "enabled": true + } + ], + "_postman_variable_scope": "environment", + "_postman_exported_at": "2021-05-07T06:51:42.799Z", + "_postman_exported_using": "Postman/8.3.0" +} \ No newline at end of file diff --git a/src/main/kotlin/de/livepoll/api/controller/AccountController.kt b/src/main/kotlin/de/livepoll/api/controller/AccountController.kt index eb29f0d7..c1ee15ce 100644 --- a/src/main/kotlin/de/livepoll/api/controller/AccountController.kt +++ b/src/main/kotlin/de/livepoll/api/controller/AccountController.kt @@ -52,9 +52,9 @@ class AccountController( return accountService.login(authRequest.username) } catch (err: EmailNotConfirmedException) { throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Please confirm your email") - } catch (_: UsernameNotFoundException) { + } catch (ex: UsernameNotFoundException) { throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Wrong username or password") - } catch (_: Exception) { + } catch (ex: Exception) { throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Wrong username or password") } } diff --git a/src/main/kotlin/de/livepoll/api/controller/PollController.kt b/src/main/kotlin/de/livepoll/api/controller/PollController.kt index ea8162d0..c602aaf7 100644 --- a/src/main/kotlin/de/livepoll/api/controller/PollController.kt +++ b/src/main/kotlin/de/livepoll/api/controller/PollController.kt @@ -4,6 +4,7 @@ import de.livepoll.api.entity.db.User import de.livepoll.api.entity.dto.PollDtoIn import de.livepoll.api.entity.dto.PollDtoOut import de.livepoll.api.entity.dto.PollItemDtoOut +import de.livepoll.api.service.AccountService import de.livepoll.api.service.PollService import de.livepoll.api.util.toDtoOut import io.swagger.annotations.Api @@ -18,6 +19,7 @@ import java.net.URI @RequestMapping("/v1/polls") class PollController( private val pollService: PollService, + private val accountService: AccountService ) { @ApiOperation(value = "Get polls", tags = ["Poll"]) @@ -37,24 +39,28 @@ class PollController( @ApiOperation(value = "Get poll", tags = ["Poll"]) @GetMapping("/{id}") fun getPoll(@PathVariable(name = "id") pollId: Long, @AuthenticationPrincipal user: User): PollDtoOut { + accountService.checkAuthorizationByPollId(pollId) return pollService.getPoll(pollId) } @ApiOperation(value = "Delete poll", tags = ["Poll"]) @DeleteMapping("/{id}") fun deletePoll(@PathVariable(name = "id") pollId: Long) { + accountService.checkAuthorizationByPollId(pollId) return pollService.deletePoll(pollId) } @ApiOperation(value = "Update slug", tags = ["Poll"]) @PutMapping("/{id}") fun updatePoll(@PathVariable(name = "id") pollId: Long, @RequestBody updatedPoll: PollDtoIn): PollDtoOut { + accountService.checkAuthorizationByPollId(pollId) return pollService.updatePoll(pollId, updatedPoll) } @ApiOperation(value = "Get poll items", tags = ["Poll item"]) @GetMapping("/{id}/poll-items") fun getPollItems(@PathVariable(name = "id") pollId: Long): List { + accountService.checkAuthorizationByPollId(pollId) return pollService.getPollItemsForPoll(pollId) } } diff --git a/src/main/kotlin/de/livepoll/api/controller/PollItemController.kt b/src/main/kotlin/de/livepoll/api/controller/PollItemController.kt index 563c76c0..079a6e38 100644 --- a/src/main/kotlin/de/livepoll/api/controller/PollItemController.kt +++ b/src/main/kotlin/de/livepoll/api/controller/PollItemController.kt @@ -4,6 +4,7 @@ import de.livepoll.api.entity.dto.MultipleChoiceItemDtoIn import de.livepoll.api.entity.dto.OpenTextItemDtoIn import de.livepoll.api.entity.dto.PollItemDtoOut import de.livepoll.api.entity.dto.QuizItemDtoIn +import de.livepoll.api.service.AccountService import de.livepoll.api.service.PollItemService import de.livepoll.api.service.PollService import io.swagger.annotations.Api @@ -17,7 +18,8 @@ import java.net.URI @RequestMapping("/v1/poll-items") class PollItemController( private val pollService: PollService, - private val pollItemService: PollItemService + private val pollItemService: PollItemService, + private val accountService: AccountService ) { //--------------------------------------------- Get ---------------------------------------------------------------- @@ -25,6 +27,7 @@ class PollItemController( @ApiOperation(value = "Get poll item", tags = ["Poll item"]) @GetMapping("/{id}") fun getPollItem(@PathVariable(name = "id") pollItemId: Long): PollItemDtoOut { + accountService.checkAuthorizationByPollItemId(pollItemId) return pollItemService.getPollItem(pollItemId) } @@ -56,8 +59,9 @@ class PollItemController( @ApiOperation(value = "Delete poll item", tags = ["Poll item"]) @DeleteMapping("/{id}") - fun deletePollItem(@PathVariable(name = "id") itemId: Long): ResponseEntity<*> { - pollItemService.deleteItem(itemId) + fun deletePollItem(@PathVariable(name = "id") pollItemId: Long): ResponseEntity<*> { + accountService.checkAuthorizationByPollItemId(pollItemId) + pollItemService.deleteItem(pollItemId) return ResponseEntity.ok("Deleted poll item") } diff --git a/src/main/kotlin/de/livepoll/api/service/AccountService.kt b/src/main/kotlin/de/livepoll/api/service/AccountService.kt index 5d0c6377..0ffc207d 100644 --- a/src/main/kotlin/de/livepoll/api/service/AccountService.kt +++ b/src/main/kotlin/de/livepoll/api/service/AccountService.kt @@ -1,24 +1,26 @@ package de.livepoll.api.service import de.livepoll.api.entity.db.BlockedToken +import de.livepoll.api.entity.db.PollItem import de.livepoll.api.entity.db.User import de.livepoll.api.entity.db.VerificationToken import de.livepoll.api.exception.EmailNotConfirmedException import de.livepoll.api.exception.UserExistsException -import de.livepoll.api.repository.BlockedTokenRepository -import de.livepoll.api.repository.UserRepository -import de.livepoll.api.repository.VerificationTokenRepository +import de.livepoll.api.repository.* import de.livepoll.api.util.JwtUtil import de.livepoll.api.util.OnCreateAccountEvent import de.livepoll.api.util.jwtCookie.CookieCipher import de.livepoll.api.util.jwtCookie.CookieUtil import org.springframework.context.ApplicationEventPublisher import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.userdetails.UsernameNotFoundException import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException +import java.security.Principal import java.util.* import javax.servlet.http.HttpServletRequest @@ -32,7 +34,9 @@ class AccountService( private val jwtUtil: JwtUtil, private val jwtUserDetailsService: JwtUserDetailsService, private val cookieCipher: CookieCipher, - private val blockedTokenRepository: BlockedTokenRepository + private val blockedTokenRepository: BlockedTokenRepository, + private val pollRepository: PollRepository, + private val pollItemRepository: PollItemRepository ) { fun createAccount(user: User): User { @@ -104,4 +108,17 @@ class AccountService( response["message"] = "Logout successful" return ResponseEntity.ok().headers(responseHeaders).body(response) } + + fun checkAuthorizationByUsername(username: String): Boolean{ + if(SecurityContextHolder.getContext().authentication.name == username) return true + throw ResponseStatusException(HttpStatus.FORBIDDEN, "You are not authorized") + } + fun checkAuthorizationByPollId(id: Long): Boolean{ + if(SecurityContextHolder.getContext().authentication.name == pollRepository.getOne(id).user.username) return true + throw ResponseStatusException(HttpStatus.FORBIDDEN, "You are not authorized") + } + fun checkAuthorizationByPollItemId(id: Long): Boolean{ + if(SecurityContextHolder.getContext().authentication.name == pollItemRepository.getOne(id).poll.user.username) return true + throw ResponseStatusException(HttpStatus.FORBIDDEN, "You are not authorized") + } } diff --git a/src/test/kotlin/de/livepoll/api/controller/v1/PollItemServiceTests.kt b/src/test/kotlin/de/livepoll/api/controller/v1/PollItemServiceTest.kt similarity index 95% rename from src/test/kotlin/de/livepoll/api/controller/v1/PollItemServiceTests.kt rename to src/test/kotlin/de/livepoll/api/controller/v1/PollItemServiceTest.kt index 93ab799c..e8957c62 100644 --- a/src/test/kotlin/de/livepoll/api/controller/v1/PollItemServiceTests.kt +++ b/src/test/kotlin/de/livepoll/api/controller/v1/PollItemServiceTest.kt @@ -1,5 +1,6 @@ package de.livepoll.api.controller.v1 +import de.livepoll.api.LivePollApplication import de.livepoll.api.entity.db.* import de.livepoll.api.entity.dto.* import de.livepoll.api.repository.* @@ -11,16 +12,24 @@ import org.junit.jupiter.api.DisplayName import org.junit.runner.RunWith import org.mockito.Mockito import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.TestConfiguration import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.context.annotation.Bean +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.TestPropertySource import org.springframework.test.context.junit4.SpringRunner import java.util.* + @RunWith(SpringRunner::class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK, classes = [LivePollApplication::class]) +@AutoConfigureMockMvc +@TestPropertySource("classpath:application-test.properties") @DisplayName("Test poll item service") -class PollItemServiceTests { +class PollItemServiceTest { @Autowired private lateinit var pollItemService: PollItemService @@ -46,6 +55,10 @@ class PollItemServiceTests { @MockBean private lateinit var quizItemAnswerRepository: QuizItemAnswerRepository + init{ + + } + // Needed, since we can't use @Autowired in unit tests // If you know a solution to this "hack", please fix it or open a GitHub issue @TestConfiguration diff --git a/src/test/kotlin/de/livepoll/api/cucumber/CucumberIntegrationTestContext.kt b/src/test/kotlin/de/livepoll/api/cucumber/CucumberIntegrationTest.kt similarity index 79% rename from src/test/kotlin/de/livepoll/api/cucumber/CucumberIntegrationTestContext.kt rename to src/test/kotlin/de/livepoll/api/cucumber/CucumberIntegrationTest.kt index 625976c6..0ae860c2 100644 --- a/src/test/kotlin/de/livepoll/api/cucumber/CucumberIntegrationTestContext.kt +++ b/src/test/kotlin/de/livepoll/api/cucumber/CucumberIntegrationTest.kt @@ -9,12 +9,13 @@ import org.apache.http.conn.ssl.SSLConnectionSocketFactory import org.apache.http.impl.client.CloseableHttpClient import org.apache.http.impl.client.HttpClients import org.assertj.core.api.Assertions.assertThat -import org.junit.runner.RunWith +import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.web.server.LocalServerPort import org.springframework.http.* import org.springframework.http.client.HttpComponentsClientHttpRequestFactory -import org.springframework.test.context.junit4.SpringRunner +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.test.context.TestPropertySource import org.springframework.web.client.RestTemplate import org.springframework.web.client.exchange import java.security.cert.X509Certificate @@ -22,29 +23,37 @@ import javax.net.ssl.SSLContext // https://github.com/Mhverma/spring-cucumber-example/blob/master/src/test/java/com/manoj/training/app/SpringCucumberIntegrationTests.java -@RunWith(SpringRunner::class) @CucumberContextConfiguration @SpringBootTest(classes = [LivePollApplication::class], webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -class CucumberIntegrationTestContext(userRepository: UserRepository) { +@TestPropertySource("classpath:application-test.properties") +class CucumberIntegrationTest( + private val userRepository: UserRepository +) { + + @Autowired + lateinit var passwordEncoder: PasswordEncoder + + @LocalServerPort + protected var port = 0 + companion object { - private const val AUTHENTICATION_ENDPOINT = "/v0/authenticate/login" + private const val AUTHENTICATION_ENDPOINT = "/v1/account/login" } object SessionCookieUtil { lateinit var sessionCookie: String } - protected final var testUser: User = userRepository.findByUsername(System.getenv("TEST_USER_NAME"))!! + protected var testUser: User? = userRepository.findByUsername("cucumber_test_user") // can't move this into companion object since // "Kotlin: Using non-JVM static members protected in the superclass companion is unsupported yet" protected val SERVER_URL = "https://localhost" - - @LocalServerPort - protected var port = 0 - protected final var restTemplate: RestTemplate + protected final val testUserName = "cucumber_test_user" + protected final val testUserPassword = "1234" + init { // https://stackoverflow.com/a/42689331/9655481 // Disable SSL certificate checking with Spring RestTemplate @@ -68,12 +77,16 @@ class CucumberIntegrationTestContext(userRepository: UserRepository) { } protected fun logInWithTestUser(): Pair { + if (userRepository.findByUsername(testUserName) == null) { + userRepository.saveAndFlush(User(0, testUserName, "email", passwordEncoder.encode(testUserPassword), true, "ROLE_USER", emptyList())) + testUser = userRepository.findByUsername(testUserName) + } // https://springbootdev.com/2017/11/21/spring-resttemplate-exchange-method/ // https://attacomsian.com/blog/spring-boot-resttemplate-post-request-json-headers val url = "${SERVER_URL}:$port$AUTHENTICATION_ENDPOINT" // request body params - val authenticationRequest = AuthenticationRequest(testUser.username, System.getenv("TEST_USER_PASSWORD")) + val authenticationRequest = AuthenticationRequest(testUserName, testUserPassword) val requestEntity: HttpEntity = HttpEntity(authenticationRequest) // make request diff --git a/src/test/kotlin/de/livepoll/api/cucumber/RunCucumberTest.kt b/src/test/kotlin/de/livepoll/api/cucumber/RunCucumberTest.kt index bdb0d1b1..c30c617c 100644 --- a/src/test/kotlin/de/livepoll/api/cucumber/RunCucumberTest.kt +++ b/src/test/kotlin/de/livepoll/api/cucumber/RunCucumberTest.kt @@ -7,6 +7,7 @@ import org.junit.runner.RunWith @RunWith(Cucumber::class) @CucumberOptions( features = ["src/test/resources/features/"], - plugin = ["pretty", "html:target/cucumber-report.html"] + plugin = ["pretty", "html:target/cucumber-report.html"], + publish = true ) class RunCucumberTest \ No newline at end of file diff --git a/src/test/kotlin/de/livepoll/api/cucumber/stepdefinitions/PollStepDefinitions.kt b/src/test/kotlin/de/livepoll/api/cucumber/stepdefinitions/PollStepDefinitions.kt index fabce97b..eb8c342b 100644 --- a/src/test/kotlin/de/livepoll/api/cucumber/stepdefinitions/PollStepDefinitions.kt +++ b/src/test/kotlin/de/livepoll/api/cucumber/stepdefinitions/PollStepDefinitions.kt @@ -1,6 +1,6 @@ package de.livepoll.api.cucumber.stepdefinitions -import de.livepoll.api.cucumber.CucumberIntegrationTestContext +import de.livepoll.api.cucumber.CucumberIntegrationTest import de.livepoll.api.entity.dto.PollDtoIn import de.livepoll.api.entity.dto.PollDtoOut import de.livepoll.api.repository.PollRepository @@ -14,14 +14,17 @@ import org.springframework.http.* import org.springframework.web.client.exchange import java.sql.Date -class PollStepDefinitions(userRepository: UserRepository, val pollRepository: PollRepository) : CucumberIntegrationTestContext(userRepository) { - private val POLLS_ENDPOINT = "/v0/users/${testUser.id}/polls" - private val CREATE_POLL_ENDPOINT = "/v0/users/${testUser.id}/poll" +class PollStepDefinitions( + userRepository: UserRepository, + private val pollRepository: PollRepository +) : CucumberIntegrationTest(userRepository) { + private val POLL_ENDPOINT = "/v1/polls" + lateinit var myPolls: ArrayList @Given("I have no polls created yet") fun deleteAllPolls() { - pollRepository.deleteByUser(testUser) + pollRepository.deleteByUser(testUser!!) } @When("I create {int} new dummy polls") @@ -35,7 +38,7 @@ class PollStepDefinitions(userRepository: UserRepository, val pollRepository: Po // Make sure we start with a new ArrayList for myPolls myPolls = ArrayList() - val url = "${SERVER_URL}:$port$CREATE_POLL_ENDPOINT" + val url = "${SERVER_URL}:$port$POLL_ENDPOINT" // request body params & headers val pollPostRequest = PollDtoIn(pollName, Date(0), Date(0), "test", null) @@ -45,9 +48,9 @@ class PollStepDefinitions(userRepository: UserRepository, val pollRepository: Po // make request val responseEntity: ResponseEntity = restTemplate.exchange( - url, - HttpMethod.POST, - requestEntity + url, + HttpMethod.POST, + requestEntity ) assertThat(responseEntity.statusCode).isEqualTo(HttpStatus.CREATED) @@ -60,9 +63,9 @@ class PollStepDefinitions(userRepository: UserRepository, val pollRepository: Po @And("I retrieve my polls") fun retrieveMyPolls() { - val url = "${SERVER_URL}:$port$POLLS_ENDPOINT" + val url = "${SERVER_URL}:$port$POLL_ENDPOINT" val pollResponseEntity = - makeGetRequestWithSessionCookie>(url, SessionCookieUtil.sessionCookie) + makeGetRequestWithSessionCookie>(url, SessionCookieUtil.sessionCookie) assertThat(pollResponseEntity.statusCode).isEqualTo(HttpStatus.OK) assertThat(pollResponseEntity.body).isNotNull for (poll in pollResponseEntity.body!!) { diff --git a/src/test/kotlin/de/livepoll/api/cucumber/stepdefinitions/UserStepDefinitions.kt b/src/test/kotlin/de/livepoll/api/cucumber/stepdefinitions/UserStepDefinitions.kt index 1f32f1ee..56ad5e1b 100644 --- a/src/test/kotlin/de/livepoll/api/cucumber/stepdefinitions/UserStepDefinitions.kt +++ b/src/test/kotlin/de/livepoll/api/cucumber/stepdefinitions/UserStepDefinitions.kt @@ -1,6 +1,10 @@ package de.livepoll.api.cucumber.stepdefinitions -import de.livepoll.api.cucumber.CucumberIntegrationTestContext +import de.livepoll.api.cucumber.CucumberIntegrationTest +import de.livepoll.api.entity.db.Poll +import de.livepoll.api.entity.db.PollItem +import de.livepoll.api.entity.db.User +import de.livepoll.api.repository.PollRepository import de.livepoll.api.repository.UserRepository import io.cucumber.java.en.And import io.cucumber.java.en.Given @@ -10,19 +14,24 @@ import org.assertj.core.api.Assertions.assertThat import org.springframework.http.* import org.springframework.web.client.RestClientResponseException import org.springframework.web.client.exchange +import java.util.* -private const val LOGOUT_ENDPOINT = "/v0/authenticate/logout" +private const val LOGOUT_ENDPOINT = "/v1/account/logout" -class UserStepDefinitions(userRepository: UserRepository) : CucumberIntegrationTestContext(userRepository) { - private val USER_ENDPOINT = "/v0/users/${testUser.id}" - private val USER_ENDPOINT_ANOTHER = "/v0/users/${testUser.id + 1}" +class UserStepDefinitions( + private val pollRepository: PollRepository, + private val userRepository: UserRepository +) : CucumberIntegrationTest(userRepository) { + + private val USER_ENDPOINT = "/v1/user" + private val POLL_ENDPOINT = "/v1/polls" lateinit var status: HttpStatus var alreadyConfirmed = false @Given("A test user exists") fun makeSureATestUserExists() { - // TODO + assertThat(testUser) } @Given("I am logged in as test user") @@ -51,10 +60,17 @@ class UserStepDefinitions(userRepository: UserRepository) : CucumberIntegrationT @And("I am not authorized to retrieve information about a different user") fun getInfoAboutDifferentUser() { - val url = "${SERVER_URL}:$port$USER_ENDPOINT_ANOTHER" + if(userRepository.findByUsername("different_user") == null){ + userRepository.saveAndFlush(User(1,"different_user", "different_email", passwordEncoder.encode("12345"), true, "ROLE_USER", emptyList())) + } + if(pollRepository.findBySlug("different_user_test_slug") == null){ + pollRepository.saveAndFlush(Poll(0, userRepository.findByUsername("different_user")!!, "different_user_test_poll", GregorianCalendar(2021, 5, 2).time, GregorianCalendar(2021, 5, 2).time, "different_user_test_slug", null, emptyList().toMutableList())) + } + val id = pollRepository.findBySlug("different_user_test_slug")!!.id + val url = "${SERVER_URL}:$port$POLL_ENDPOINT/${id}" try { - val userResponseEntity = makeGetRequestWithSessionCookie(url, SessionCookieUtil.sessionCookie) - assertThat(userResponseEntity.statusCode).isEqualTo(HttpStatus.FORBIDDEN) + val pollResponseEntity = makeGetRequestWithSessionCookie(url, SessionCookieUtil.sessionCookie) + assertThat(pollResponseEntity.statusCode).isEqualTo(HttpStatus.FORBIDDEN) } catch (e: RestClientResponseException) { assertThat(e.rawStatusCode).isEqualTo(HttpStatus.FORBIDDEN.value()) } diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index d601281b..4c0c0758 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -5,10 +5,8 @@ spring.application.name = "Live-Poll API" # https://howtodoinjava.com/spring-boot2/h2-database-example/ # https://www.baeldung.com/spring-jpa-test-in-memory-database -spring.datasource.url = ${LIVE_POLL_H2_URL} +spring.datasource.url = jdbc:h2:mem:livepoll spring.datasource.driverClassName = org.h2.Driver -spring.datasource.username = ${LIVE_POLL_MYSQL_USER} -spring.datasource.password = ${LIVE_POLL_MYSQL_PASSWORD} # https://medium.com/@harittweets/how-to-connect-to-h2-database-during-development-testing-using-spring-boot-44bbb287570 # Enabling H2 Console @@ -19,9 +17,4 @@ spring.h2.console.path = /h2-console spring.jpa.show-sql = true spring.jpa.hibernate.ddl-auto = create-drop -spring.jpa.properties.hibernate.dialect = org.hibernate.dialect.H2Dialect - -server.ssl.enabled=${LIVE_POLL_HTTPS_ENABLED} -server.ssl.key-store-type=PKCS12 -server.ssl.key-store=classpath:cert/localhost.p12 -server.ssl.key-store-password=${LIVE_POLL_HTTPS_CERT_PASSWORD} \ No newline at end of file +spring.jpa.properties.hibernate.dialect = org.hibernate.dialect.H2Dialect \ No newline at end of file From e1f4a7ae281a15b80fc21c82f5850f71255653fd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 9 May 2021 11:53:18 +0200 Subject: [PATCH 07/25] Bump kotlin.version from 1.4.32 to 1.5.0 (#77) Bumps `kotlin.version` from 1.4.32 to 1.5.0. Updates `kotlin-maven-allopen` from 1.4.32 to 1.5.0 Updates `kotlin-maven-noarg` from 1.4.32 to 1.5.0 Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Niklas-23 <65356992+Niklas-23@users.noreply.github.com> Co-authored-by: Marc Auberer --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index c6ad6e21..bb65cda5 100644 --- a/pom.xml +++ b/pom.xml @@ -16,7 +16,7 @@ 11 - 1.4.32 + 1.5.0 1.4.32 From 87749a571c025905dc15166168d9b31565e1cd4b Mon Sep 17 00:00:00 2001 From: Niklas-23 <65356992+Niklas-23@users.noreply.github.com> Date: Mon, 10 May 2021 15:02:12 +0200 Subject: [PATCH 08/25] Update poll item and new registration email (#84) * Send poll id back when current item is null * Updated integration test * Optimze websocket service * New registration email * Bump version to 0.6.0 to reflect GH release * Remove deprecated Bintray repository & pom cleanup * Convert application.properties to application.yml * Adjust CI workflow to also run on feature branches * Update poll item * New env variable added for frontend URL * Adjust postman collection to reflect PUT endpoints * Replace http with https in postman environment * Cleanup * Include new endpoints in integration test * Fix Codecov badge in README * Update poll item service and authorization check Co-authored-by: Marc Auberer Co-authored-by: Ordinateur-Hack --- .github/workflows/ci.yml | 2 +- README.md | 10 +- pom.xml | 444 +++++++++--------- postman/Livepoll.dev.postman_environment.json | 6 +- postman/Livepoll.postman_collection.json | 326 ++++++++++++- .../api/controller/PollItemController.kt | 23 + .../api/entity/db/MultipleChoiceItem.kt | 11 +- .../de/livepoll/api/entity/db/OpenTextItem.kt | 5 +- .../api/entity/db/OpenTextItemAnswer.kt | 2 +- .../kotlin/de/livepoll/api/entity/db/Poll.kt | 2 +- .../de/livepoll/api/entity/db/PollItem.kt | 4 +- .../de/livepoll/api/entity/db/QuizItem.kt | 3 +- .../livepoll/api/entity/db/QuizItemAnswer.kt | 2 +- .../de/livepoll/api/service/AccountService.kt | 33 +- .../livepoll/api/service/PollItemService.kt | 93 +++- .../de/livepoll/api/service/PollService.kt | 10 +- .../livepoll/api/service/WebSocketService.kt | 60 +-- .../de/livepoll/api/util/AccountListener.kt | 53 ++- .../de/livepoll/api/util/CustomModelMapper.kt | 2 +- .../api/util/websocket/SubscribeListener.kt | 13 +- src/main/resources/application.properties | 22 - src/main/resources/application.yml | 24 + src/main/resources/logo.png | Bin 0 -> 51338 bytes .../api/controller/v1/PollItemServiceTest.kt | 3 +- .../api/cucumber/CucumberIntegrationTest.kt | 2 +- .../resources/application-test.properties | 20 - src/test/resources/application-test.yml | 14 + 27 files changed, 829 insertions(+), 360 deletions(-) delete mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/logo.png delete mode 100644 src/test/resources/application-test.properties create mode 100644 src/test/resources/application-test.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1ee8a71..649e10a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ name: CI on: push: - branches: [ develop ] + branches: [ develop, feature/** ] pull_request: branches: [ develop ] diff --git a/README.md b/README.md index 2d7922b1..92362de5 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,11 @@

Easy to use, web-based poll management system.

- + - - - + + +

@@ -22,4 +22,4 @@ ## Security policy If you want to report a security vulnerability, please read our security policy in the [SECURITY.md](SECURITY.md) file. -© Live-Poll 2020-2021 \ No newline at end of file +© Live-Poll 2020-2021 diff --git a/pom.xml b/pom.xml index bb65cda5..579e241b 100644 --- a/pom.xml +++ b/pom.xml @@ -1,218 +1,232 @@ - 4.0.0 - - org.springframework.boot - spring-boot-starter-parent - 2.4.5 - - - de.live-poll - api - 0.0.1 - Live-Poll - API Backend for Live-Poll - - - 11 - 1.5.0 - 1.4.32 - - - - - org.springframework.boot - spring-boot-starter-websocket - - - org.springframework.boot - spring-boot-starter-data-jpa - - - org.springframework.boot - spring-boot-starter-mail - - - org.springframework.boot - spring-boot-starter-web - - - com.fasterxml.jackson.module - jackson-module-kotlin - - - org.jetbrains.kotlin - kotlin-reflect - - - org.jetbrains.kotlin - kotlin-stdlib-jdk8 - - - com.h2database - h2 - test - - - mysql - mysql-connector-java - runtime - - - org.springframework.boot - spring-boot-starter-test - test - - - org.junit.vintage - junit-vintage-engine - test - - - org.hamcrest - hamcrest-core - - - - - org.springframework.boot - spring-boot-starter-security - - - org.springframework - spring-test - - - io.cucumber - cucumber-java - 6.10.3 - test - - - io.cucumber - cucumber-junit - 6.10.3 - test - - - junit - junit - 4.13.2 - test - - - net.masterthought - cucumber-reporting - 5.5.3 - - - io.cucumber - cucumber-spring - 6.10.3 - test - - - io.springfox - springfox-boot-starter - 3.0.0 - - - - io.jsonwebtoken - jjwt - 0.9.1 - - - org.apache.httpcomponents - httpclient - 4.5.13 - test - - - - - ${project.basedir}/src/main/kotlin - ${project.basedir}/src/test/kotlin - - - org.springframework.boot - spring-boot-maven-plugin - - - org.jetbrains.kotlin - kotlin-maven-plugin - - - -Xjsr305=strict - - - spring - jpa - - - - - org.jetbrains.kotlin - kotlin-maven-allopen - ${kotlin.version} - - - org.jetbrains.kotlin - kotlin-maven-noarg - ${kotlin.version} - - - - - org.jetbrains.dokka - dokka-maven-plugin - ${dokka.version} - - - pre-site - - dokka - - - - - - - org.jetbrains.dokka - kotlin-as-java-plugin - ${dokka.version} - - - - - - org.jacoco - jacoco-maven-plugin - 0.8.7 - - - - prepare-agent - - - - report - test - - report - - - - - - - - - - jcenter - JCenter - https://jcenter.bintray.com/ - - + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.4.5 + + + de.live-poll + api + 0.0.6 + Live-Poll + API Backend for Live-Poll + + + 11 + 1.4.32 + 1.4.32 + + + + + org.springframework.boot + spring-boot-starter-websocket + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + org.springframework.boot + spring-boot-starter-mail + + + + org.springframework.boot + spring-boot-starter-web + + + + com.fasterxml.jackson.module + jackson-module-kotlin + + + + org.jetbrains.kotlin + kotlin-reflect + + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + + + + com.h2database + h2 + test + + + + mysql + mysql-connector-java + runtime + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.junit.vintage + junit-vintage-engine + test + + + org.hamcrest + hamcrest-core + + + + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework + spring-test + + + + io.cucumber + cucumber-java + 6.10.3 + test + + + + io.cucumber + cucumber-junit + 6.10.3 + test + + + + junit + junit + 4.13.2 + test + + + + net.masterthought + cucumber-reporting + 5.5.3 + + + + io.cucumber + cucumber-spring + 6.10.3 + test + + + + io.springfox + springfox-boot-starter + 3.0.0 + + + + io.jsonwebtoken + jjwt + 0.9.1 + + + + org.apache.httpcomponents + httpclient + 4.5.13 + test + + + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/test/kotlin + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.jetbrains.kotlin + kotlin-maven-plugin + + + -Xjsr305=strict + + + spring + jpa + + + + + org.jetbrains.kotlin + kotlin-maven-allopen + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-maven-noarg + ${kotlin.version} + + + + + + org.jetbrains.dokka + dokka-maven-plugin + ${dokka.version} + + + pre-site + + dokka + + + + + + + org.jetbrains.dokka + kotlin-as-java-plugin + ${dokka.version} + + + + + + + org.jacoco + jacoco-maven-plugin + 0.8.7 + + + + prepare-agent + + + + report + test + + report + + + + + + diff --git a/postman/Livepoll.dev.postman_environment.json b/postman/Livepoll.dev.postman_environment.json index a7c46892..b8eff3e3 100644 --- a/postman/Livepoll.dev.postman_environment.json +++ b/postman/Livepoll.dev.postman_environment.json @@ -1,14 +1,14 @@ { - "id": "e6bc2f3d-b160-4f6f-9d7a-04b03380ae32", + "id": "8ac27cf1-2b69-4479-80d7-2d1e2d09c819", "name": "Livepoll Dev", "values": [ { "key": "base-url", - "value": "http://localhost:8080", + "value": "https://localhost:8080", "enabled": true } ], "_postman_variable_scope": "environment", - "_postman_exported_at": "2021-05-07T06:51:42.799Z", + "_postman_exported_at": "2021-05-10T00:42:20.174Z", "_postman_exported_using": "Postman/8.3.0" } \ No newline at end of file diff --git a/postman/Livepoll.postman_collection.json b/postman/Livepoll.postman_collection.json index 84387f34..fd7ab484 100644 --- a/postman/Livepoll.postman_collection.json +++ b/postman/Livepoll.postman_collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "4348fa9f-7a6d-4190-b745-470d6a86d0ce", + "_postman_id": "2006a217-01e3-449d-9bee-f79a38e381d1", "name": "Livepoll", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, @@ -338,7 +338,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\r\n \"name\": \"update\",\r\n \"startDate\": \"1610705932\",\r\n \"endDate\":\"1610705932\",\r\n \"slug\": \"1234\",\r\n \"currentItem\": 51\r\n \r\n}", + "raw": "{\r\n \"name\": \"update\",\r\n \"startDate\": \"1610705932\",\r\n \"endDate\":\"1610705932\",\r\n \"slug\": \"1234\",\r\n \"currentItem\": 126\r\n \r\n}", "options": { "raw": { "language": "json" @@ -437,6 +437,48 @@ }, "response": [] }, + { + "name": "Update multiple choice item", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {\r", + " pm.response.to.have.status(200);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-multiplechoice-question-update\",\r\n \"position\": 1,\r\n \"answers\": [\"postman-multiplechoice-answer-1-update\", \"postman-multiplechoice-answer-2-update\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base-url}}/v1/poll-items/multiple-choice/{{poll-item-id}}", + "host": [ + "{{base-url}}" + ], + "path": [ + "v1", + "poll-items", + "multiple-choice", + "{{poll-item-id}}" + ] + } + }, + "response": [] + }, { "name": "Create quiz item", "event": [ @@ -479,7 +521,7 @@ "response": [] }, { - "name": "Create open text item", + "name": "Update quiz item", "event": [ { "listen": "test", @@ -493,6 +535,48 @@ } } ], + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"position\": 1,\r\n \"question\": \"postman-quiz-question-update\",\r\n \"answers\": [ \"correct option update\", \"wrong option update\", \"wrong option update\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base-url}}/v1/poll-items/quiz/{{poll-item-id}}", + "host": [ + "{{base-url}}" + ], + "path": [ + "v1", + "poll-items", + "quiz", + "{{poll-item-id}}" + ] + } + }, + "response": [] + }, + { + "name": "Create open text item", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 201\", function () {\r", + " pm.response.to.have.status(201);\r", + "});" + ], + "type": "text/javascript" + } + } + ], "request": { "method": "POST", "header": [], @@ -519,6 +603,48 @@ }, "response": [] }, + { + "name": "Update open text item", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {\r", + " pm.response.to.have.status(200);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"position\": 1,\r\n \"question\": \"postman-open-text-question update\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base-url}}/v1/poll-items/open-text/{{poll-item-id}}", + "host": [ + "{{base-url}}" + ], + "path": [ + "v1", + "poll-items", + "open-text", + "{{poll-item-id}}" + ] + } + }, + "response": [] + }, { "name": "Delete poll item", "event": [ @@ -766,7 +892,9 @@ "exec": [ "pm.test(\"Status code is 201\", function () {\r", " pm.response.to.have.status(201);\r", - "});" + "});\r", + "pm.globals.set(\"poll-item-id-multiple-choice\", pm.response.json().itemId)\r", + "pm.globals.set(\"poll-item-id\", pm.response.json().itemId)" ], "type": "text/javascript" } @@ -807,7 +935,8 @@ "exec": [ "pm.test(\"Status code is 201\", function () {\r", " pm.response.to.have.status(201);\r", - "});" + "});\r", + "pm.globals.set(\"poll-item-id-quiz\", pm.response.json().itemId)" ], "type": "text/javascript" } @@ -818,7 +947,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-quiz-question\",\r\n \"position\": 1,\r\n \"answers\": [ { \"answer\" : \"postman-quiz-answer-1\", \"isCorrect\" : true}, { \"answer\" : \"postman-quiz-answer-2\", \"isCorrect\" : false}]\r\n}", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"position\": 1,\r\n \"question\": \"postman-quiz-question\",\r\n \"answers\": [ \"correct option\", \"wrong option\", \"wrong option\"]\r\n}", "options": { "raw": { "language": "json" @@ -839,6 +968,49 @@ }, "response": [] }, + { + "name": "Create open text item", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 201\", function () {\r", + " pm.response.to.have.status(201);\r", + "});\r", + "pm.globals.set(\"poll-item-id-open-text\", pm.response.json().itemId)\r", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"position\": 1,\r\n \"question\": \"postman-open-text-question\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base-url}}/v1/poll-items/open-text", + "host": [ + "{{base-url}}" + ], + "path": [ + "v1", + "poll-items", + "open-text" + ] + } + }, + "response": [] + }, { "name": "Get poll items", "event": [ @@ -850,8 +1022,6 @@ " pm.response.to.have.status(200);\r", "});\r", "pm.globals.set(\"poll-items\", pm.response.json()) \r", - "let pollItems = pm.globals.get(\"poll-items\")\r", - "pm.globals.set(\"poll-item-id\", pollItems[0].itemId)\r", "pm.globals.set(\"poll-items-counter\", 1)" ], "type": "text/javascript" @@ -977,6 +1147,132 @@ }, "response": [] }, + { + "name": "Update multiple choice item Copy", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {\r", + " pm.response.to.have.status(200);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-multiplechoice-question-update\",\r\n \"position\": 1,\r\n \"answers\": [\"postman-multiplechoice-answer-1-update\", \"postman-multiplechoice-answer-2-update\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base-url}}/v1/poll-items/multiple-choice/{{poll-item-id-multiple-choice}}", + "host": [ + "{{base-url}}" + ], + "path": [ + "v1", + "poll-items", + "multiple-choice", + "{{poll-item-id-multiple-choice}}" + ] + } + }, + "response": [] + }, + { + "name": "Update quiz item", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {\r", + " pm.response.to.have.status(200);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"position\": 1,\r\n \"question\": \"postman-quiz-question-update\",\r\n \"answers\": [ \"correct option update\", \"wrong option update\", \"wrong option update\"]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base-url}}/v1/poll-items/quiz/{{poll-item-id-quiz}}", + "host": [ + "{{base-url}}" + ], + "path": [ + "v1", + "poll-items", + "quiz", + "{{poll-item-id-quiz}}" + ] + } + }, + "response": [] + }, + { + "name": "Update open text item", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {\r", + " pm.response.to.have.status(200);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"position\": 1,\r\n \"question\": \"postman-open-text-question update\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base-url}}/v1/poll-items/open-text/{{poll-item-id-open-text}}", + "host": [ + "{{base-url}}" + ], + "path": [ + "v1", + "poll-items", + "open-text", + "{{poll-item-id-open-text}}" + ] + } + }, + "response": [] + }, { "name": "Delete poll item", "event": [ @@ -984,11 +1280,14 @@ "listen": "test", "script": { "exec": [ + "pm.test(\"Status code is 200\", function () {\r", + " pm.response.to.have.status(200);\r", + "});\r", "const url = \"localhost:8080/v1/poll-items/\" + pm.globals.get(\"poll-item-id\");\r", "pm.sendRequest(url, function(error, response){\r", " pm.test(\"Poll item does not exist anymore\", function(){\r", - " pm.expect(response).to.have.property('code', 404)\r", - " pm.expect(response).to.have.property('status', 'Not Found')\r", + " pm.expect(response).to.have.property('code', 403)\r", + " pm.expect(response).to.have.property('status', 'Forbidden')\r", " })\r", "})" ], @@ -1020,11 +1319,14 @@ "listen": "test", "script": { "exec": [ + "pm.test(\"Status code is 200\", function () {\r", + " pm.response.to.have.status(200);\r", + "});\r", "const url = \"localhost:8080/v1/polls/\" + pm.globals.get(\"poll-id\");\r", "pm.sendRequest(url, function(error, response){\r", " pm.test(\"Poll item does not exist anymore\", function(){\r", - " pm.expect(response).to.have.property('code', 404)\r", - " pm.expect(response).to.have.property('status', 'Not Found')\r", + " pm.expect(response).to.have.property('code', 403)\r", + " pm.expect(response).to.have.property('status', 'Forbidden')\r", " })\r", "})\r", "\r", diff --git a/src/main/kotlin/de/livepoll/api/controller/PollItemController.kt b/src/main/kotlin/de/livepoll/api/controller/PollItemController.kt index 079a6e38..7a48cf1b 100644 --- a/src/main/kotlin/de/livepoll/api/controller/PollItemController.kt +++ b/src/main/kotlin/de/livepoll/api/controller/PollItemController.kt @@ -55,6 +55,29 @@ class PollItemController( } + //-------------------------------------------- Update -------------------------------------------------------------- + @ApiOperation(value = "Update multiple choice item", tags = ["Poll item"]) + @PutMapping("/multiple-choice/{pollItemId}") + fun updateMultipleChoiceItem(@RequestBody updatedItem: MultipleChoiceItemDtoIn, @PathVariable(name="pollItemId") pollItemId: Long): ResponseEntity<*> { + accountService.checkAuthorizationByPollItemId(pollItemId) + return ResponseEntity.ok(pollItemService.updateMultipleChoiceItem(pollItemId, updatedItem)) + } + + @ApiOperation(value = "Update quiz item", tags = ["Poll item"]) + @PutMapping("/quiz/{pollItemId}") + fun updateQuizItem(@RequestBody updatedItem: QuizItemDtoIn, @PathVariable(name="pollItemId") pollItemId: Long): ResponseEntity<*> { + accountService.checkAuthorizationByPollItemId(pollItemId) + return ResponseEntity.ok(pollItemService.updateQuizItem(pollItemId, updatedItem)) + } + + @ApiOperation(value = "Update open text item", tags = ["Poll item"]) + @PutMapping("/open-text/{pollItemId}") + fun updateOpenTextItem(@RequestBody updatedItem: OpenTextItemDtoIn, @PathVariable(name="pollItemId") pollItemId: Long): ResponseEntity<*> { + accountService.checkAuthorizationByPollItemId(pollItemId) + return ResponseEntity.ok(pollItemService.updateOpenTextItem(pollItemId, updatedItem)) + } + + //-------------------------------------------- Delete -------------------------------------------------------------- @ApiOperation(value = "Delete poll item", tags = ["Poll item"]) diff --git a/src/main/kotlin/de/livepoll/api/entity/db/MultipleChoiceItem.kt b/src/main/kotlin/de/livepoll/api/entity/db/MultipleChoiceItem.kt index a2620a8f..ac18fae6 100644 --- a/src/main/kotlin/de/livepoll/api/entity/db/MultipleChoiceItem.kt +++ b/src/main/kotlin/de/livepoll/api/entity/db/MultipleChoiceItem.kt @@ -1,9 +1,6 @@ package de.livepoll.api.entity.db -import javax.persistence.Column -import javax.persistence.Entity -import javax.persistence.OneToMany -import javax.persistence.Table +import javax.persistence.* @Entity @Table(name = "multiple_choice_item") @@ -17,12 +14,12 @@ class MultipleChoiceItem( question: String, @Column(name = "allow_multiple_answers") - val allowMultipleAnswers: Boolean, + var allowMultipleAnswers: Boolean, @Column(name = "allow_blank_field") - val allowBlankField: Boolean, + var allowBlankField: Boolean, - @OneToMany(mappedBy = "multipleChoiceItem") + @OneToMany(mappedBy = "multipleChoiceItem", cascade = [CascadeType.ALL], orphanRemoval = true) var answers: MutableList ) : PollItem(id, poll, question, position, PollItemType.MULTIPLE_CHOICE) diff --git a/src/main/kotlin/de/livepoll/api/entity/db/OpenTextItem.kt b/src/main/kotlin/de/livepoll/api/entity/db/OpenTextItem.kt index 14c5801d..12788ece 100644 --- a/src/main/kotlin/de/livepoll/api/entity/db/OpenTextItem.kt +++ b/src/main/kotlin/de/livepoll/api/entity/db/OpenTextItem.kt @@ -1,5 +1,6 @@ package de.livepoll.api.entity.db +import javax.persistence.CascadeType import javax.persistence.Entity import javax.persistence.OneToMany import javax.persistence.Table @@ -15,7 +16,7 @@ class OpenTextItem( position: Int, - @OneToMany(mappedBy = "openTextItem") - val answers: MutableList + @OneToMany(mappedBy = "openTextItem", cascade = [CascadeType.ALL], orphanRemoval = true) + var answers: MutableList ) : PollItem(id, poll, question, position, PollItemType.OPEN_TEXT) diff --git a/src/main/kotlin/de/livepoll/api/entity/db/OpenTextItemAnswer.kt b/src/main/kotlin/de/livepoll/api/entity/db/OpenTextItemAnswer.kt index 0163534d..d992083b 100644 --- a/src/main/kotlin/de/livepoll/api/entity/db/OpenTextItemAnswer.kt +++ b/src/main/kotlin/de/livepoll/api/entity/db/OpenTextItemAnswer.kt @@ -5,7 +5,7 @@ import org.springframework.lang.NonNull import javax.persistence.* @Entity -@Table(name = "quiz_item_answer") +@Table(name = "open_text_item_answer") class OpenTextItemAnswer( @Id diff --git a/src/main/kotlin/de/livepoll/api/entity/db/Poll.kt b/src/main/kotlin/de/livepoll/api/entity/db/Poll.kt index 2877d5cc..a261184d 100644 --- a/src/main/kotlin/de/livepoll/api/entity/db/Poll.kt +++ b/src/main/kotlin/de/livepoll/api/entity/db/Poll.kt @@ -30,6 +30,6 @@ data class Poll( var currentItem : Long?, @JsonIgnore - @OneToMany(mappedBy = "poll", cascade = [CascadeType.ALL]) + @OneToMany(mappedBy = "poll", cascade = [CascadeType.ALL], orphanRemoval = true) var pollItems: MutableList ) diff --git a/src/main/kotlin/de/livepoll/api/entity/db/PollItem.kt b/src/main/kotlin/de/livepoll/api/entity/db/PollItem.kt index 8911dce9..20bde5dd 100644 --- a/src/main/kotlin/de/livepoll/api/entity/db/PollItem.kt +++ b/src/main/kotlin/de/livepoll/api/entity/db/PollItem.kt @@ -22,11 +22,11 @@ open class PollItem( @NonNull @Column - open val question: String, + open var question: String, @NonNull @Column - open val position: Int, + open var position: Int, @NonNull @Column diff --git a/src/main/kotlin/de/livepoll/api/entity/db/QuizItem.kt b/src/main/kotlin/de/livepoll/api/entity/db/QuizItem.kt index b4ab4359..1407036a 100644 --- a/src/main/kotlin/de/livepoll/api/entity/db/QuizItem.kt +++ b/src/main/kotlin/de/livepoll/api/entity/db/QuizItem.kt @@ -1,5 +1,6 @@ package de.livepoll.api.entity.db +import javax.persistence.CascadeType import javax.persistence.Entity import javax.persistence.OneToMany import javax.persistence.Table @@ -15,7 +16,7 @@ class QuizItem( question: String, - @OneToMany(mappedBy = "quizItem") + @OneToMany(mappedBy = "quizItem", cascade = [CascadeType.ALL], orphanRemoval = true) var answers: MutableList ) : PollItem(id, poll, question, position, PollItemType.QUIZ) \ No newline at end of file diff --git a/src/main/kotlin/de/livepoll/api/entity/db/QuizItemAnswer.kt b/src/main/kotlin/de/livepoll/api/entity/db/QuizItemAnswer.kt index 05713ad6..044dfeba 100644 --- a/src/main/kotlin/de/livepoll/api/entity/db/QuizItemAnswer.kt +++ b/src/main/kotlin/de/livepoll/api/entity/db/QuizItemAnswer.kt @@ -24,7 +24,7 @@ class QuizItemAnswer( val selectionOption: String, @Column(name = "is_correct") - val isCorrect: Boolean, + var isCorrect: Boolean, @Column(name = "answer_count") var answerCount: Int diff --git a/src/main/kotlin/de/livepoll/api/service/AccountService.kt b/src/main/kotlin/de/livepoll/api/service/AccountService.kt index 0ffc207d..bebde98b 100644 --- a/src/main/kotlin/de/livepoll/api/service/AccountService.kt +++ b/src/main/kotlin/de/livepoll/api/service/AccountService.kt @@ -12,6 +12,7 @@ import de.livepoll.api.util.OnCreateAccountEvent import de.livepoll.api.util.jwtCookie.CookieCipher import de.livepoll.api.util.jwtCookie.CookieUtil import org.springframework.context.ApplicationEventPublisher +import org.springframework.dao.DataAccessException import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity @@ -20,8 +21,8 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.web.bind.annotation.RestController import org.springframework.web.server.ResponseStatusException -import java.security.Principal import java.util.* +import javax.persistence.EntityNotFoundException import javax.servlet.http.HttpServletRequest @RestController @@ -109,16 +110,30 @@ class AccountService( return ResponseEntity.ok().headers(responseHeaders).body(response) } - fun checkAuthorizationByUsername(username: String): Boolean{ - if(SecurityContextHolder.getContext().authentication.name == username) return true + fun checkAuthorizationByUsername(username: String): Boolean { + if (SecurityContextHolder.getContext().authentication.name == username) + return true throw ResponseStatusException(HttpStatus.FORBIDDEN, "You are not authorized") } - fun checkAuthorizationByPollId(id: Long): Boolean{ - if(SecurityContextHolder.getContext().authentication.name == pollRepository.getOne(id).user.username) return true - throw ResponseStatusException(HttpStatus.FORBIDDEN, "You are not authorized") + + fun checkAuthorizationByPollId(id: Long): Boolean { + try { + if (SecurityContextHolder.getContext().authentication.name == pollRepository.getOne(id).user.username) + return true + throw ResponseStatusException(HttpStatus.FORBIDDEN, "You are not authorized") + } catch (ex: DataAccessException) { + throw ResponseStatusException(HttpStatus.FORBIDDEN, "You are not authorized") + } } - fun checkAuthorizationByPollItemId(id: Long): Boolean{ - if(SecurityContextHolder.getContext().authentication.name == pollItemRepository.getOne(id).poll.user.username) return true - throw ResponseStatusException(HttpStatus.FORBIDDEN, "You are not authorized") + + fun checkAuthorizationByPollItemId(id: Long): Boolean { + try { + if (SecurityContextHolder.getContext().authentication.name == pollItemRepository.getOne(id).poll.user.username) + return true + throw ResponseStatusException(HttpStatus.FORBIDDEN, "You are not authorized") + } catch (ex: EntityNotFoundException) { + throw ResponseStatusException(HttpStatus.FORBIDDEN, "You are not authorized") + } + } } diff --git a/src/main/kotlin/de/livepoll/api/service/PollItemService.kt b/src/main/kotlin/de/livepoll/api/service/PollItemService.kt index 429e07e2..0244a763 100644 --- a/src/main/kotlin/de/livepoll/api/service/PollItemService.kt +++ b/src/main/kotlin/de/livepoll/api/service/PollItemService.kt @@ -8,6 +8,8 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import org.springframework.web.server.ResponseStatusException +import javax.sql.rowset.Predicate +import javax.swing.text.MutableAttributeSet @Service class PollItemService { @@ -110,7 +112,7 @@ class PollItemService { ) // Quiz item answers quizItem.answers = item.answers.mapIndexed { index, element -> - QuizItemAnswer(0, quizItem, element, index == 0, 0) + QuizItemAnswer(0, quizItem, element, index == 0, 0) }.toMutableList() quizItemRepository.saveAndFlush(quizItem) @@ -138,4 +140,93 @@ class PollItemService { } } + + //-------------------------------------------- Update -------------------------------------------------------------- + + fun updateMultipleChoiceItem(pollItemId: Long, pollItem: MultipleChoiceItemDtoIn): MultipleChoiceItemDtoOut { + multipleChoiceItemRepository.findById(pollItemId) + .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Poll item not found") } + .run { + val newAnswers = pollItem.answers.toMutableList() + val removeAnswer = mutableListOf() + this.answers.forEach { + if (it.answerCount != 0) { + if(pollItem.answers.contains(it.selectionOption)){ + newAnswers.remove(it.selectionOption) + }else{ + this.answers.remove(it) + } + }else{ + if(!pollItem.answers.contains(it.selectionOption)){ + removeAnswer.add(it) + } + } + } + removeAnswer.forEach { + this.answers.remove(it) + } + newAnswers.forEach { + this.answers.add(MultipleChoiceItemAnswer(0, this, it, 0)) + } + + this.question = pollItem.question + this.allowMultipleAnswers = pollItem.allowMultipleAnswers + this.allowBlankField = pollItem.allowBlankField + this.position = pollItem.position + + return pollItemRepository.saveAndFlush(this).toDtoOut() + } + } + + fun updateQuizItem(pollItemId: Long, pollItem: QuizItemDtoIn): QuizItemDtoOut { + quizItemRepository.findById(pollItemId) + .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Poll item not found") } + .run { + val newAnswers = pollItem.answers.toMutableList() + val removeAnswer = mutableListOf() + this.answers.forEach { + if (it.answerCount != 0) { + if(pollItem.answers.contains(it.selectionOption)){ + newAnswers.remove(it.selectionOption) + it.isCorrect = false + }else{ + this.answers.remove(it) + } + }else{ + if(!pollItem.answers.contains(it.selectionOption)){ + removeAnswer.add(it) + } + } + } + removeAnswer.forEach { + this.answers.remove(it) + } + newAnswers.forEach { + this.answers.add(QuizItemAnswer(0, this, it, false, 0)) + } + val newCorrectOne = this.answers.find{it.selectionOption == pollItem.answers[0]}!! + if(newCorrectOne.answerCount == 0){ + this.answers.find{it.selectionOption == pollItem.answers[0]}!!.isCorrect = true + } + + this.question = pollItem.question + this.position = pollItem.position + + return quizItemRepository.saveAndFlush(this).toDtoOut() + } + } + + fun updateOpenTextItem(pollItemId: Long, pollItem: OpenTextItemDtoIn): OpenTextItemDtoOut { + openTextItemRepository.findById(pollItemId) + .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Poll item not found") } + .run { + if (!this.answers.isEmpty()) { + throw ResponseStatusException(HttpStatus.CONFLICT, "This item can not be updated anymore") + } + this.question = pollItem.question + this.position = pollItem.position + return openTextItemRepository.saveAndFlush(this).toDtoOut() + } + } + } diff --git a/src/main/kotlin/de/livepoll/api/service/PollService.kt b/src/main/kotlin/de/livepoll/api/service/PollService.kt index b14dc326..ae52792d 100644 --- a/src/main/kotlin/de/livepoll/api/service/PollService.kt +++ b/src/main/kotlin/de/livepoll/api/service/PollService.kt @@ -38,6 +38,7 @@ class PollService( } } + //-------------------------------------------- Create -------------------------------------------------------------- fun createPoll(pollDto: PollDtoIn, userId: Long): PollDtoOut { @@ -79,6 +80,7 @@ class PollService( } } + //-------------------------------------------- Update -------------------------------------------------------------- fun updatePoll(pollId: Long, poll: PollDtoIn): PollDtoOut { @@ -91,12 +93,8 @@ class PollService( if (poll.slug != null && isSlugUnique(poll.slug)) { this.slug = poll.slug } - if (poll.currentItem != null) { - this.currentItem = poll.currentItem - webSocketService.sendCurrentItem(this.slug, this.currentItem!!) - } else { - this.currentItem = null - } + this.currentItem = poll.currentItem + webSocketService.sendCurrentItem(this.slug, this.id, this.currentItem) return pollRepository.saveAndFlush(this).toDtoOut() } } diff --git a/src/main/kotlin/de/livepoll/api/service/WebSocketService.kt b/src/main/kotlin/de/livepoll/api/service/WebSocketService.kt index 352146f5..7282c852 100644 --- a/src/main/kotlin/de/livepoll/api/service/WebSocketService.kt +++ b/src/main/kotlin/de/livepoll/api/service/WebSocketService.kt @@ -12,27 +12,39 @@ import de.livepoll.api.repository.OpenTextItemAnswerRepository import de.livepoll.api.repository.OpenTextItemRepository import de.livepoll.api.repository.QuizItemAnswerRepository import de.livepoll.api.util.toDbEntity +import org.springframework.http.HttpStatus import org.springframework.messaging.simp.SimpMessageSendingOperations import org.springframework.messaging.simp.user.SimpUserRegistry import org.springframework.stereotype.Controller +import org.springframework.web.server.ResponseStatusException @Controller class WebSocketService( - private val messagingTemplate: SimpMessageSendingOperations, - private val pollItemService: PollItemService, - private val openTextItemAnswerRepository: OpenTextItemAnswerRepository, - private val simpUserRegistry: SimpUserRegistry, - private val multipleChoiceItemAnswerRepository: MultipleChoiceItemAnswerRepository, - private val quizItemAnswerRepository: QuizItemAnswerRepository, - private val openTextItemRepository: OpenTextItemRepository + private val messagingTemplate: SimpMessageSendingOperations, + private val pollItemService: PollItemService, + private val openTextItemAnswerRepository: OpenTextItemAnswerRepository, + private val simpUserRegistry: SimpUserRegistry, + private val multipleChoiceItemAnswerRepository: MultipleChoiceItemAnswerRepository, + private val quizItemAnswerRepository: QuizItemAnswerRepository, + private val openTextItemRepository: OpenTextItemRepository ) { - fun sendCurrentItem(slug: String, currentItemId: Long) { - val item: PollItemDtoOut = pollItemService.getPollItem(currentItemId) + fun sendCurrentItem(slug: String, pollId: Long, currentItemId: Long?) { val url = "/v1/websocket/poll/$slug" - simpUserRegistry.users.forEach { - messagingTemplate.convertAndSendToUser(it.name, url, item) + if (currentItemId != null) { + val item: PollItemDtoOut = pollItemService.getPollItem(currentItemId) + if (pollId == item.pollId) { + simpUserRegistry.users.forEach { + messagingTemplate.convertAndSendToUser(it.name, url, item) + } + } else { + throw ResponseStatusException(HttpStatus.CONFLICT, "Item is not part of poll") + } + } else { + simpUserRegistry.users.forEach { + messagingTemplate.convertAndSendToUser(it.name, url, "{\"id\":$pollId}") + } } } @@ -40,25 +52,28 @@ class WebSocketService( println("PAYLOAD: $payload") val mapper = ObjectMapper() val type: String = mapper.readValue(payload, Map::class.java)["type"].toString() - when (getPollItemType(type)) { + when (type) { // Multiple Choice - PollItemType.MULTIPLE_CHOICE -> { - val obj: MultipleChoiceItemParticipantAnswerDtoIn = mapper.readValue(payload, MultipleChoiceItemParticipantAnswerDtoIn::class.java) + PollItemType.MULTIPLE_CHOICE.representation -> { + val obj: MultipleChoiceItemParticipantAnswerDtoIn = + mapper.readValue(payload, MultipleChoiceItemParticipantAnswerDtoIn::class.java) val multipleChoiceItemAnswer = multipleChoiceItemAnswerRepository.getOne(obj.id) multipleChoiceItemAnswer.answerCount++ multipleChoiceItemAnswerRepository.saveAndFlush(multipleChoiceItemAnswer) } // Open text - PollItemType.OPEN_TEXT -> { - val obj: OpenTextItemParticipantAnswerDtoIn = mapper.readValue(payload, OpenTextItemParticipantAnswerDtoIn::class.java) + PollItemType.OPEN_TEXT.representation -> { + val obj: OpenTextItemParticipantAnswerDtoIn = + mapper.readValue(payload, OpenTextItemParticipantAnswerDtoIn::class.java) val pollItem = openTextItemRepository.getOne(pollItemId) openTextItemAnswerRepository.saveAndFlush(obj.toDbEntity(pollItem)) } // Quiz - PollItemType.QUIZ -> { - val obj: QuizItemParticipantAnswerDtoIn = mapper.readValue(payload, QuizItemParticipantAnswerDtoIn::class.java) + PollItemType.QUIZ.representation -> { + val obj: QuizItemParticipantAnswerDtoIn = + mapper.readValue(payload, QuizItemParticipantAnswerDtoIn::class.java) val quizItemAnswer = quizItemAnswerRepository.getOne(obj.id) quizItemAnswer.answerCount++ quizItemAnswerRepository.saveAndFlush(quizItemAnswer) @@ -66,13 +81,4 @@ class WebSocketService( } } - private fun getPollItemType(type: String): PollItemType { - return when (type.toLowerCase()) { - "multiple-choice" -> PollItemType.MULTIPLE_CHOICE - "open-text" -> PollItemType.OPEN_TEXT - "quiz" -> PollItemType.QUIZ - else -> throw Exception("Item type not allowed") - } - } - } diff --git a/src/main/kotlin/de/livepoll/api/util/AccountListener.kt b/src/main/kotlin/de/livepoll/api/util/AccountListener.kt index 0e7dd299..5ae49451 100644 --- a/src/main/kotlin/de/livepoll/api/util/AccountListener.kt +++ b/src/main/kotlin/de/livepoll/api/util/AccountListener.kt @@ -3,15 +3,18 @@ package de.livepoll.api.util import de.livepoll.api.service.AccountService import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.ApplicationListener -import org.springframework.mail.SimpleMailMessage import org.springframework.mail.javamail.JavaMailSender +import org.springframework.mail.javamail.MimeMessageHelper import org.springframework.stereotype.Component +import org.springframework.util.ResourceUtils import java.util.* +import javax.mail.internet.MimeMessage + @Component -class AccountListener: ApplicationListener { +class AccountListener : ApplicationListener { - private val serverUrl = System.getenv("LIVE_POLL_SERVER_URL") + private val frontendUrl = System.getenv("LIVE_POLL_FRONTEND_URL") @Autowired private lateinit var accountService: AccountService @@ -23,21 +26,43 @@ class AccountListener: ApplicationListener { this.confirmCreateAccount(event) } - private fun confirmCreateAccount(event: OnCreateAccountEvent){ + private fun confirmCreateAccount(event: OnCreateAccountEvent) { val user = event.user val token = UUID.randomUUID().toString() accountService.createVerificationToken(user, token) val recipientAddress = user.email - val subject = "Account Confirmation Live-Poll" - val confirmationUrl = event.appUrl + "/v1/account/confirm?token=$token" - val message = "Please confirm your email: " - - javaMailSender.send(SimpleMailMessage().apply { - setTo(recipientAddress) - setSubject(subject) - setText("$message\r\n$serverUrl$confirmationUrl") - }) - } + val subject = "Your Live-Poll registration" + val confirmationUrl = "$frontendUrl/activate/$token" + val mimeMessage: MimeMessage = javaMailSender.createMimeMessage() + val helper = MimeMessageHelper(mimeMessage, true) + helper.setFrom("noreply@live-poll.de") + helper.setTo(recipientAddress) + helper.setSubject(subject) + helper.setText( + "" + + "" + + "" + + "
" + + "
Dear ${user.username}," + + "
thank you for using Live-Poll." + + "
" + + "
Live-Poll is an open-source live-polling application that you can use totally free, no matter if you are a private person, school, university, society, small or big business etc. Our idea arose from the lack of free live voting/polling software on the Internet that has a nice user flow and is easy to use.
" + + "
" + + "
Please confirm your registration by clicking here.
" + + "
" + + "
Best regards,
" + + "
Live-Poll
" + + "
" + + "
" + + "", true + ) + helper.addInline( + "logo", + ResourceUtils.getFile("classpath:logo.png") + ) + + javaMailSender.send(mimeMessage) + } } \ No newline at end of file diff --git a/src/main/kotlin/de/livepoll/api/util/CustomModelMapper.kt b/src/main/kotlin/de/livepoll/api/util/CustomModelMapper.kt index a09ba269..6f7cd3de 100644 --- a/src/main/kotlin/de/livepoll/api/util/CustomModelMapper.kt +++ b/src/main/kotlin/de/livepoll/api/util/CustomModelMapper.kt @@ -51,6 +51,6 @@ fun OpenTextItemAnswer.toDtoOut(): OpenTextItemAnswerDtoOut { return OpenTextItemAnswerDtoOut(this.id, this.answer) } -fun OpenTextItemParticipantAnswerDtoIn.toDbEntity(item: OpenTextItem): OpenTextItemAnswer{ +fun OpenTextItemParticipantAnswerDtoIn.toDbEntity(item: OpenTextItem): OpenTextItemAnswer { return OpenTextItemAnswer(0, item, this.answer) } diff --git a/src/main/kotlin/de/livepoll/api/util/websocket/SubscribeListener.kt b/src/main/kotlin/de/livepoll/api/util/websocket/SubscribeListener.kt index 37988f17..a6cb7ed2 100644 --- a/src/main/kotlin/de/livepoll/api/util/websocket/SubscribeListener.kt +++ b/src/main/kotlin/de/livepoll/api/util/websocket/SubscribeListener.kt @@ -12,9 +12,9 @@ import org.springframework.web.socket.messaging.SessionSubscribeEvent @Component class SubscribeListener( - private val messagingTemplate: SimpMessageSendingOperations, - private val pollRepository: PollRepository, - private val pollItemService: PollItemService + private val messagingTemplate: SimpMessageSendingOperations, + private val pollRepository: PollRepository, + private val pollItemService: PollItemService ) : ApplicationListener { @Transactional @@ -24,13 +24,14 @@ class SubscribeListener( if (poll == null) { throw ResponseStatusException(HttpStatus.NOT_FOUND) } else { + val url = "/v1/websocket/poll/$slug" if (poll.currentItem == null) { - throw ResponseStatusException(HttpStatus.CONTINUE) + messagingTemplate.convertAndSendToUser(event.user!!.name, url, "{\"id\":${poll.id}}") } else { val pollItemDto = pollItemService.getPollItem(poll.currentItem!!) - val url = "/v1/websocket/poll/$slug" messagingTemplate.convertAndSendToUser(event.user!!.name, url, pollItemDto) } } } -} \ No newline at end of file + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index a88ccd86..00000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1,22 +0,0 @@ -api.version = 0 - -server.port = 8080 -server.http2.enabled = true -server.compression.enabled = true -server.compression.mime-types = text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml -server.compression.min-response-size = 1024 - -spring.application.name = "Live-Poll API" - -spring.datasource.url = ${LIVE_POLL_MYSQL_URL} -spring.datasource.username = ${LIVE_POLL_MYSQL_USER} -spring.datasource.password = ${LIVE_POLL_MYSQL_PASSWORD} - -spring.jpa.show-sql = true -spring.jpa.hibernate.ddl-auto = update -spring.jpa.properties.hibernate.dialect = org.hibernate.dialect.MySQL5Dialect - -server.ssl.enabled=${LIVE_POLL_HTTPS_ENABLED} -server.ssl.key-store-type=PKCS12 -server.ssl.key-store=classpath:cert/localhost.p12 -server.ssl.key-store-password=${LIVE_POLL_HTTPS_CERT_PASSWORD} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 00000000..9cf52098 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,24 @@ +server: + port: 8080 + http2.enabled: true + compression: + enabled: true + mime-types: text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml + min-response-size: 1024 + +spring: + application.name: Live-Poll API + datasource: + url: ${LIVE_POLL_MYSQL_URL} + username: ${LIVE_POLL_MYSQL_USER} + password: ${LIVE_POLL_MYSQL_PASSWORD} + jpa: + show-sql: true + hibernate.ddl-auto: update + properties.hibernate.dialect: org.hibernate.dialect.MySQL8Dialect + +server.ssl: + enabled: ${LIVE_POLL_HTTPS_ENABLED:true} + key-store-type: PKCS12 + key-store: classpath:cert/localhost.p12 + key-store-password: ${LIVE_POLL_HTTPS_CERT_PASSWORD} \ No newline at end of file diff --git a/src/main/resources/logo.png b/src/main/resources/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..a8b1cadaa89ba22b715cff80ae5f86978e0e02b5 GIT binary patch literal 51338 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 MEvEuX?3xDsKhwWN#sB~S literal 0 HcmV?d00001 diff --git a/src/test/kotlin/de/livepoll/api/controller/v1/PollItemServiceTest.kt b/src/test/kotlin/de/livepoll/api/controller/v1/PollItemServiceTest.kt index e8957c62..7fb18577 100644 --- a/src/test/kotlin/de/livepoll/api/controller/v1/PollItemServiceTest.kt +++ b/src/test/kotlin/de/livepoll/api/controller/v1/PollItemServiceTest.kt @@ -17,7 +17,6 @@ import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.TestConfiguration import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.context.annotation.Bean -import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.TestPropertySource import org.springframework.test.context.junit4.SpringRunner import java.util.* @@ -27,7 +26,7 @@ import java.util.* @RunWith(SpringRunner::class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK, classes = [LivePollApplication::class]) @AutoConfigureMockMvc -@TestPropertySource("classpath:application-test.properties") +@TestPropertySource("classpath:application-test.yml") @DisplayName("Test poll item service") class PollItemServiceTest { diff --git a/src/test/kotlin/de/livepoll/api/cucumber/CucumberIntegrationTest.kt b/src/test/kotlin/de/livepoll/api/cucumber/CucumberIntegrationTest.kt index 0ae860c2..82a4aa98 100644 --- a/src/test/kotlin/de/livepoll/api/cucumber/CucumberIntegrationTest.kt +++ b/src/test/kotlin/de/livepoll/api/cucumber/CucumberIntegrationTest.kt @@ -25,7 +25,7 @@ import javax.net.ssl.SSLContext // https://github.com/Mhverma/spring-cucumber-example/blob/master/src/test/java/com/manoj/training/app/SpringCucumberIntegrationTests.java @CucumberContextConfiguration @SpringBootTest(classes = [LivePollApplication::class], webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@TestPropertySource("classpath:application-test.properties") +@TestPropertySource("classpath:application-test.yml") class CucumberIntegrationTest( private val userRepository: UserRepository ) { diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties deleted file mode 100644 index 4c0c0758..00000000 --- a/src/test/resources/application-test.properties +++ /dev/null @@ -1,20 +0,0 @@ -api.version = 0 - -server.port = 8080 -spring.application.name = "Live-Poll API" - -# https://howtodoinjava.com/spring-boot2/h2-database-example/ -# https://www.baeldung.com/spring-jpa-test-in-memory-database -spring.datasource.url = jdbc:h2:mem:livepoll -spring.datasource.driverClassName = org.h2.Driver - -# https://medium.com/@harittweets/how-to-connect-to-h2-database-during-development-testing-using-spring-boot-44bbb287570 -# Enabling H2 Console -spring.h2.console.enabled = true - -# Custom H2 Console URL -spring.h2.console.path = /h2-console - -spring.jpa.show-sql = true -spring.jpa.hibernate.ddl-auto = create-drop -spring.jpa.properties.hibernate.dialect = org.hibernate.dialect.H2Dialect \ No newline at end of file diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 00000000..7d941f7a --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,14 @@ +server.port: 8080 +spring: + application.name: Live-Poll API + datasource: # https://howtodoinjava.com/spring-boot2/h2-database-example/, https://www.baeldung.com/spring-jpa-test-in-memory-database + url: jdbc:h2:mem:livepoll + driverClassName: org.h2.Driver + h2: # https://medium.com/@harittweets/how-to-connect-to-h2-database-during-development-testing-using-spring-boot-44bbb287570 + console: + enabled: true + path: /h2-console + jpa: + show-sql: true + hibernate.ddl-auto: create-drop + properties.hibernate.dialect: org.hibernate.dialect.H2Dialect \ No newline at end of file From ab13b007513312064169a293957c93eb0f98aca2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 May 2021 13:35:33 +0200 Subject: [PATCH 09/25] Bump kotlin.version from 1.4.32 to 1.5.0 (#86) Bumps `kotlin.version` from 1.4.32 to 1.5.0. Updates `kotlin-maven-allopen` from 1.4.32 to 1.5.0 Updates `kotlin-maven-noarg` from 1.4.32 to 1.5.0 Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 579e241b..4aa6ef87 100644 --- a/pom.xml +++ b/pom.xml @@ -16,7 +16,7 @@ 11 - 1.4.32 + 1.5.0 1.4.32 From 478c1096e1717c75ea55200531870db3a98a4371 Mon Sep 17 00:00:00 2001 From: Niklas-23 <65356992+Niklas-23@users.noreply.github.com> Date: Tue, 11 May 2021 17:05:38 +0200 Subject: [PATCH 10/25] Fix tests (#87) Co-authored-by: Marc Auberer --- .../de/livepoll/api/controller/v1/PollItemServiceTest.kt | 3 ++- .../kotlin/de/livepoll/api/cucumber/CucumberIntegrationTest.kt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/test/kotlin/de/livepoll/api/controller/v1/PollItemServiceTest.kt b/src/test/kotlin/de/livepoll/api/controller/v1/PollItemServiceTest.kt index 7fb18577..ce116247 100644 --- a/src/test/kotlin/de/livepoll/api/controller/v1/PollItemServiceTest.kt +++ b/src/test/kotlin/de/livepoll/api/controller/v1/PollItemServiceTest.kt @@ -17,6 +17,7 @@ import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.TestConfiguration import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.context.annotation.Bean +import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.TestPropertySource import org.springframework.test.context.junit4.SpringRunner import java.util.* @@ -26,7 +27,7 @@ import java.util.* @RunWith(SpringRunner::class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK, classes = [LivePollApplication::class]) @AutoConfigureMockMvc -@TestPropertySource("classpath:application-test.yml") +@ActiveProfiles("test") @DisplayName("Test poll item service") class PollItemServiceTest { diff --git a/src/test/kotlin/de/livepoll/api/cucumber/CucumberIntegrationTest.kt b/src/test/kotlin/de/livepoll/api/cucumber/CucumberIntegrationTest.kt index 82a4aa98..8f3a15b5 100644 --- a/src/test/kotlin/de/livepoll/api/cucumber/CucumberIntegrationTest.kt +++ b/src/test/kotlin/de/livepoll/api/cucumber/CucumberIntegrationTest.kt @@ -15,6 +15,7 @@ import org.springframework.boot.web.server.LocalServerPort import org.springframework.http.* import org.springframework.http.client.HttpComponentsClientHttpRequestFactory import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.TestPropertySource import org.springframework.web.client.RestTemplate import org.springframework.web.client.exchange @@ -25,7 +26,7 @@ import javax.net.ssl.SSLContext // https://github.com/Mhverma/spring-cucumber-example/blob/master/src/test/java/com/manoj/training/app/SpringCucumberIntegrationTests.java @CucumberContextConfiguration @SpringBootTest(classes = [LivePollApplication::class], webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@TestPropertySource("classpath:application-test.yml") +@ActiveProfiles("test") class CucumberIntegrationTest( private val userRepository: UserRepository ) { From dfd48e33115f420779fa7b5b43259aaf678e918e Mon Sep 17 00:00:00 2001 From: Marc Auberer Date: Sat, 15 May 2021 14:06:38 +0200 Subject: [PATCH 11/25] Add Newman to CI workflow (#85) * Add Newman to CI workflow * Fix typo in version * Add compose file to simulate dev environment * Add CommandLineRunner to setup postman user account in test mode * Remove unused env vars in workflow * Add command to check Docker * Update postman collection to support Newman cookies * Fix test scrips for delete steps --- .github/scripts/docker-compose.yml | 51 ++++++ .github/workflows/ci.yml | 26 ++- pom.xml | 7 +- postman/Livepoll.postman_collection.json | 155 ++++++++++++++---- .../de/livepoll/api/LivePollApplication.kt | 13 +- .../de/livepoll/api/config/SecurityConfig.kt | 1 + .../api/controller/AccountController.kt | 1 - .../livepoll/api/controller/PollController.kt | 36 ++-- .../api/controller/PollItemController.kt | 24 ++- .../api/controller/WebSocketController.kt | 2 - .../de/livepoll/api/service/AccountService.kt | 7 + .../livepoll/api/service/WebSocketService.kt | 4 +- src/main/resources/application.yml | 4 +- 13 files changed, 262 insertions(+), 69 deletions(-) create mode 100644 .github/scripts/docker-compose.yml diff --git a/.github/scripts/docker-compose.yml b/.github/scripts/docker-compose.yml new file mode 100644 index 00000000..cbdf05ea --- /dev/null +++ b/.github/scripts/docker-compose.yml @@ -0,0 +1,51 @@ +version: "3.9" +services: + db: + image: mysql:8.0 + networks: + - app-db + environment: + MYSQL_ROOT_PASSWORD: qCNyq2SazD9vHqre5PPR7dCLs84Jhk + MYSQL_DATABASE: livepoll + MYSQL_USER: livepoll + MYSQL_PASSWORD: M3uBcPLbM7RmgX4C3wAUej6WPzq886 + healthcheck: + test: ["CMD", "mysqladmin", "ping"] + interval: 10s + retries: 10 + timeout: 5s + start_period: 40s + api: + build: ../.. + networks: + - app-db + ports: + - 8080:8080 + environment: + LIVE_POLL_MYSQL_URL: jdbc:mysql://db:3306/livepoll + LIVE_POLL_MYSQL_USER: livepoll + LIVE_POLL_MYSQL_PASSWORD: M3uBcPLbM7RmgX4C3wAUej6WPzq886 + LIVE_POLL_DEV_URL: localhost:4200 + LIVE_POLL_SERVER_URL: localhost:8080 + LIVE_POLL_MAIL_HOST: ${LIVE_POLL_MAIL_HOST} + LIVE_POLL_MAIL_PORT: ${LIVE_POLL_MAIL_PORT} + LIVE_POLL_MAIL_USERNAME: ${LIVE_POLL_MAIL_USERNAME} + LIVE_POLL_MAIL_PASSWORD: ${LIVE_POLL_MAIL_PASSWORD} + LIVE_POLL_JWT_AUTH_COOKIE_NAME: jaQ83KNSgsquPRGy8b8HraKB5kUQC3 + LIVE_POLL_JWT_COOKIE_KEY_VALUE: 4Vdx8rg84SvGYbhNwdsS8s6ZwH94LC + LIVE_POLL_JWT_SECRET: xyqCc3h3Z2DSYvz7ELEQMsv4U4b7jg + LIVE_POLL_HTTPS_ENABLED: "true" + LIVE_POLL_HTTPS_CERT_PASSWORD: ${LIVE_POLL_HTTPS_CERT_PASSWORD} + LIVE_POLL_POSTMAN: "true" + depends_on: + db: + condition: service_healthy + healthcheck: + test: "curl --fail --silent localhost:8080/actuator/health | grep UP || exit 1" + interval: 10s + timeout: 5s + retries: 10 + start_period: 40s + +networks: + app-db: {} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 649e10a8..2e6eee4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,10 +15,12 @@ jobs: steps: - name: Checkout uses: actions/checkout@v2 + - name: Set up JDK 1.11 uses: actions/setup-java@v1 with: java-version: 1.11 + - name: Build and test project with Maven run: mvn -B package --file pom.xml --batch-mode --update-snapshots clean verify env: @@ -36,4 +38,26 @@ jobs: LIVE_POLL_JWT_SECRET: ${{ secrets.API_LIVE_POLL_JWT_SECRET }} LIVE_POLL_HTTPS_ENABLED: ${{ secrets.API_LIVE_POLL_HTTPS_ENABLED }} LIVE_POLL_HTTPS_CERT_PASSWORD: ${{ secrets.API_LIVE_POLL_HTTPS_CERT_PASSWORD }} - CUCUMBER_PUBLISH_TOKEN: ${{ secrets.CUCUMBER_PUBLISH_TOKEN }} \ No newline at end of file + CUCUMBER_PUBLISH_TOKEN: ${{ secrets.CUCUMBER_PUBLISH_TOKEN }} + + - name: Setup testing environment + working-directory: ./.github/scripts + run: docker-compose up -d + env: + LIVE_POLL_MAIL_HOST: ${{ secrets.API_LIVE_POLL_MAIL_HOST }} + LIVE_POLL_MAIL_PORT: ${{ secrets.API_LIVE_POLL_MAIL_PORT }} + LIVE_POLL_MAIL_USERNAME: ${{ secrets.API_LIVE_POLL_MAIL_USERNAME }} + LIVE_POLL_MAIL_PASSWORD: ${{ secrets.API_LIVE_POLL_MAIL_PASSWORD }} + LIVE_POLL_HTTPS_CERT_PASSWORD: ${{ secrets.API_LIVE_POLL_HTTPS_CERT_PASSWORD }} + + - name: Check Docker + run: docker ps + + - name: Install Newman + run: sudo npm i -g newman + + - name: API healthcheck + run: curl -sSLf --retry-delay 5 --retry 5 --retry-connrefused --insecure http://example.org > /dev/null + + - name: Run Newman tests + run: newman run ./postman/Livepoll.postman_collection.json --iteration-count 3 --folder "Integration Test" --insecure \ No newline at end of file diff --git a/pom.xml b/pom.xml index 4aa6ef87..943f3be1 100644 --- a/pom.xml +++ b/pom.xml @@ -10,7 +10,7 @@ de.live-poll api - 0.0.6 + 0.6.0 Live-Poll API Backend for Live-Poll @@ -148,6 +148,11 @@ 4.5.13 test + + + org.springframework.boot + spring-boot-starter-actuator + diff --git a/postman/Livepoll.postman_collection.json b/postman/Livepoll.postman_collection.json index fd7ab484..bd226969 100644 --- a/postman/Livepoll.postman_collection.json +++ b/postman/Livepoll.postman_collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "2006a217-01e3-449d-9bee-f79a38e381d1", + "_postman_id": "3db6a2a6-89c4-47ef-8186-f90672f63c9d", "name": "Livepoll", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, @@ -695,7 +695,9 @@ "exec": [ "pm.test(\"Status code is 200\", function () {\r", " pm.response.to.have.status(200);\r", - "});" + "});\r", + "const cookies = pm.response.headers.all().filter(headerObj => headerObj.key === 'Set-Cookie').map(headerObj => headerObj.value);\r", + "pm.environment.set('cookies', cookies.join(';'));" ], "type": "text/javascript" } @@ -756,7 +758,13 @@ ], "request": { "method": "GET", - "header": [], + "header": [ + { + "key": "Cookie", + "value": "{{cookies}}", + "type": "text" + } + ], "url": { "raw": "{{base-url}}/v1/user", "host": [ @@ -787,7 +795,13 @@ ], "request": { "method": "POST", - "header": [], + "header": [ + { + "key": "Cookie", + "value": "{{cookies}}", + "type": "text" + } + ], "body": { "mode": "raw", "raw": "{\r\n \"name\": \"postman-poll\",\r\n \"startDate\": \"1610705932\",\r\n \"endDate\":\"1610705932\"\r\n}", @@ -828,7 +842,13 @@ ], "request": { "method": "GET", - "header": [], + "header": [ + { + "key": "Cookie", + "value": "{{cookies}}", + "type": "text" + } + ], "url": { "raw": "{{base-url}}/v1/polls", "host": [ @@ -868,7 +888,13 @@ ], "request": { "method": "GET", - "header": [], + "header": [ + { + "key": "Cookie", + "value": "{{cookies}}", + "type": "text" + } + ], "url": { "raw": "{{base-url}}/v1/polls/{{poll-id}}", "host": [ @@ -902,7 +928,13 @@ ], "request": { "method": "POST", - "header": [], + "header": [ + { + "key": "Cookie", + "value": "{{cookies}}", + "type": "text" + } + ], "body": { "mode": "raw", "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-multiplechoice-question\",\r\n \"position\": 1,\r\n \"answers\": [\"postman-multiplechoice-answer-1\", \"postman-multiplechoice-answer-2\"]\r\n}", @@ -944,7 +976,13 @@ ], "request": { "method": "POST", - "header": [], + "header": [ + { + "key": "Cookie", + "value": "{{cookies}}", + "type": "text" + } + ], "body": { "mode": "raw", "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"position\": 1,\r\n \"question\": \"postman-quiz-question\",\r\n \"answers\": [ \"correct option\", \"wrong option\", \"wrong option\"]\r\n}", @@ -987,7 +1025,13 @@ ], "request": { "method": "POST", - "header": [], + "header": [ + { + "key": "Cookie", + "value": "{{cookies}}", + "type": "text" + } + ], "body": { "mode": "raw", "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"position\": 1,\r\n \"question\": \"postman-open-text-question\"\r\n}", @@ -1030,7 +1074,13 @@ ], "request": { "method": "GET", - "header": [], + "header": [ + { + "key": "Cookie", + "value": "{{cookies}}", + "type": "text" + } + ], "url": { "raw": "{{base-url}}/v1/polls/{{poll-id}}/poll-items", "host": [ @@ -1082,7 +1132,13 @@ ], "request": { "method": "GET", - "header": [], + "header": [ + { + "key": "Cookie", + "value": "{{cookies}}", + "type": "text" + } + ], "url": { "raw": "{{base-url}}/v1/poll-items/{{poll-item-id}}", "host": [ @@ -1123,7 +1179,13 @@ ], "request": { "method": "PUT", - "header": [], + "header": [ + { + "key": "Cookie", + "value": "{{cookies}}", + "type": "text" + } + ], "body": { "mode": "raw", "raw": "{\r\n \"name\": \"{{$randomProductName}}\",\r\n \"startDate\": \"1610705932\",\r\n \"endDate\":\"1610705932\",\r\n \"slug\": \"12345\",\r\n \"currentItem\": {{poll-item-id}}\r\n \r\n}", @@ -1148,7 +1210,7 @@ "response": [] }, { - "name": "Update multiple choice item Copy", + "name": "Update multiple choice item", "event": [ { "listen": "test", @@ -1164,7 +1226,13 @@ ], "request": { "method": "PUT", - "header": [], + "header": [ + { + "key": "Cookie", + "value": "{{cookies}}", + "type": "text" + } + ], "body": { "mode": "raw", "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-multiplechoice-question-update\",\r\n \"position\": 1,\r\n \"answers\": [\"postman-multiplechoice-answer-1-update\", \"postman-multiplechoice-answer-2-update\"]\r\n}", @@ -1206,7 +1274,13 @@ ], "request": { "method": "PUT", - "header": [], + "header": [ + { + "key": "Cookie", + "value": "{{cookies}}", + "type": "text" + } + ], "body": { "mode": "raw", "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"position\": 1,\r\n \"question\": \"postman-quiz-question-update\",\r\n \"answers\": [ \"correct option update\", \"wrong option update\", \"wrong option update\"]\r\n}", @@ -1248,7 +1322,13 @@ ], "request": { "method": "PUT", - "header": [], + "header": [ + { + "key": "Cookie", + "value": "{{cookies}}", + "type": "text" + } + ], "body": { "mode": "raw", "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"position\": 1,\r\n \"question\": \"postman-open-text-question update\"\r\n}", @@ -1283,13 +1363,13 @@ "pm.test(\"Status code is 200\", function () {\r", " pm.response.to.have.status(200);\r", "});\r", - "const url = \"localhost:8080/v1/poll-items/\" + pm.globals.get(\"poll-item-id\");\r", - "pm.sendRequest(url, function(error, response){\r", - " pm.test(\"Poll item does not exist anymore\", function(){\r", - " pm.expect(response).to.have.property('code', 403)\r", - " pm.expect(response).to.have.property('status', 'Forbidden')\r", + "/*const url = \"localhost:8080/v1/poll-items/\" + pm.globals.get(\"poll-item-id\");\r", + "pm.sendRequest(url, function(error, response) {\r", + " pm.test(\"Poll item does not exist anymore\", function() {\r", + " pm.expect(response).to.have.property('code', 403)\r", + " pm.expect(response).to.have.property('status', 'Forbidden')\r", " })\r", - "})" + "})*/" ], "type": "text/javascript" } @@ -1297,7 +1377,13 @@ ], "request": { "method": "DELETE", - "header": [], + "header": [ + { + "key": "Cookie", + "value": "{{cookies}}", + "type": "text" + } + ], "url": { "raw": "{{base-url}}/v1/poll-items/{{poll-item-id}}", "host": [ @@ -1322,18 +1408,19 @@ "pm.test(\"Status code is 200\", function () {\r", " pm.response.to.have.status(200);\r", "});\r", - "const url = \"localhost:8080/v1/polls/\" + pm.globals.get(\"poll-id\");\r", - "pm.sendRequest(url, function(error, response){\r", - " pm.test(\"Poll item does not exist anymore\", function(){\r", - " pm.expect(response).to.have.property('code', 403)\r", - " pm.expect(response).to.have.property('status', 'Forbidden')\r", + "/*const url = \"localhost:8080/v1/polls/\" + pm.globals.get(\"poll-id\");\r", + "pm.sendRequest(url, function(error, response) {\r", + " pm.test(\"Poll item does not exist anymore\", function() {\r", + " pm.expect(response).to.have.property('code', 403)\r", + " pm.expect(response).to.have.property('status', 'Forbidden')\r", " })\r", - "})\r", + "})*/\r", "\r", "pm.globals.unset(\"poll-id\")\r", "pm.globals.unset(\"poll-item-id\")\r", "pm.globals.unset(\"poll-items\")\r", - "pm.globals.unset(\"poll-items-counter\")" + "pm.globals.unset(\"poll-items-counter\")\r", + "pm.globals.unset(\"cookies\")" ], "type": "text/javascript" } @@ -1341,7 +1428,13 @@ ], "request": { "method": "DELETE", - "header": [], + "header": [ + { + "key": "Cookie", + "value": "{{cookies}}", + "type": "text" + } + ], "url": { "raw": "{{base-url}}/v1/polls/{{poll-id}}", "host": [ diff --git a/src/main/kotlin/de/livepoll/api/LivePollApplication.kt b/src/main/kotlin/de/livepoll/api/LivePollApplication.kt index 2d1f4bbc..4d1ecef0 100644 --- a/src/main/kotlin/de/livepoll/api/LivePollApplication.kt +++ b/src/main/kotlin/de/livepoll/api/LivePollApplication.kt @@ -1,10 +1,21 @@ package de.livepoll.api +import de.livepoll.api.entity.db.User +import de.livepoll.api.repository.UserRepository +import de.livepoll.api.service.AccountService +import org.springframework.boot.CommandLineRunner import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication @SpringBootApplication(scanBasePackages = ["de.livepoll.api"]) -class LivePollApplication +class LivePollApplication( + private val accountService: AccountService, +): CommandLineRunner { + override fun run(vararg args: String?) { + // Create postman user, when started in testing environment + if (System.getenv("LIVE_POLL_POSTMAN") == "true") accountService.createPostmanAccount() + } +} fun main(args: Array) { runApplication(*args) diff --git a/src/main/kotlin/de/livepoll/api/config/SecurityConfig.kt b/src/main/kotlin/de/livepoll/api/config/SecurityConfig.kt index 8ed12a7d..53d39aa6 100644 --- a/src/main/kotlin/de/livepoll/api/config/SecurityConfig.kt +++ b/src/main/kotlin/de/livepoll/api/config/SecurityConfig.kt @@ -28,6 +28,7 @@ class SecurityConfig( .antMatchers("/v1/account/confirm").permitAll() .antMatchers("/v1/account/login").permitAll() .antMatchers("/v1/websocket/**").permitAll() + .antMatchers("/actuator/**").permitAll() //.antMatchers("/admin").hasRole("ADMIN") // TODO: introduce ROLE_ADMIN authority later on .anyRequest().authenticated() .and() diff --git a/src/main/kotlin/de/livepoll/api/controller/AccountController.kt b/src/main/kotlin/de/livepoll/api/controller/AccountController.kt index c1ee15ce..25582a73 100644 --- a/src/main/kotlin/de/livepoll/api/controller/AccountController.kt +++ b/src/main/kotlin/de/livepoll/api/controller/AccountController.kt @@ -64,5 +64,4 @@ class AccountController( fun logout(httpServletRequest: HttpServletRequest): ResponseEntity<*> { return accountService.logout(httpServletRequest) } - } diff --git a/src/main/kotlin/de/livepoll/api/controller/PollController.kt b/src/main/kotlin/de/livepoll/api/controller/PollController.kt index c602aaf7..cf574618 100644 --- a/src/main/kotlin/de/livepoll/api/controller/PollController.kt +++ b/src/main/kotlin/de/livepoll/api/controller/PollController.kt @@ -22,12 +22,7 @@ class PollController( private val accountService: AccountService ) { - @ApiOperation(value = "Get polls", tags = ["Poll"]) - @GetMapping - fun getPolls(@AuthenticationPrincipal user: User): ResponseEntity<*> { - val pollsOut = user.polls.map { it.toDtoOut() } - return ResponseEntity.ok().body(pollsOut) - } + //-------------------------------------------- Create -------------------------------------------------------------- @ApiOperation(value = "Create poll", tags = ["Poll"]) @PostMapping @@ -36,6 +31,8 @@ class PollController( return ResponseEntity.created(URI(newPoll.name)).body(addedPoll) } + //--------------------------------------------- Get ---------------------------------------------------------------- + @ApiOperation(value = "Get poll", tags = ["Poll"]) @GetMapping("/{id}") fun getPoll(@PathVariable(name = "id") pollId: Long, @AuthenticationPrincipal user: User): PollDtoOut { @@ -43,13 +40,22 @@ class PollController( return pollService.getPoll(pollId) } - @ApiOperation(value = "Delete poll", tags = ["Poll"]) - @DeleteMapping("/{id}") - fun deletePoll(@PathVariable(name = "id") pollId: Long) { + @ApiOperation(value = "Get polls", tags = ["Poll"]) + @GetMapping + fun getPolls(@AuthenticationPrincipal user: User): ResponseEntity<*> { + val pollsOut = user.polls.map { it.toDtoOut() } + return ResponseEntity.ok().body(pollsOut) + } + + @ApiOperation(value = "Get poll items", tags = ["Poll item"]) + @GetMapping("/{id}/poll-items") + fun getPollItems(@PathVariable(name = "id") pollId: Long): List { accountService.checkAuthorizationByPollId(pollId) - return pollService.deletePoll(pollId) + return pollService.getPollItemsForPoll(pollId) } + //-------------------------------------------- Update -------------------------------------------------------------- + @ApiOperation(value = "Update slug", tags = ["Poll"]) @PutMapping("/{id}") fun updatePoll(@PathVariable(name = "id") pollId: Long, @RequestBody updatedPoll: PollDtoIn): PollDtoOut { @@ -57,10 +63,12 @@ class PollController( return pollService.updatePoll(pollId, updatedPoll) } - @ApiOperation(value = "Get poll items", tags = ["Poll item"]) - @GetMapping("/{id}/poll-items") - fun getPollItems(@PathVariable(name = "id") pollId: Long): List { + //-------------------------------------------- Delete -------------------------------------------------------------- + + @ApiOperation(value = "Delete poll", tags = ["Poll"]) + @DeleteMapping("/{id}") + fun deletePoll(@PathVariable(name = "id") pollId: Long) { accountService.checkAuthorizationByPollId(pollId) - return pollService.getPollItemsForPoll(pollId) + return pollService.deletePoll(pollId) } } diff --git a/src/main/kotlin/de/livepoll/api/controller/PollItemController.kt b/src/main/kotlin/de/livepoll/api/controller/PollItemController.kt index 7a48cf1b..7ef84e0c 100644 --- a/src/main/kotlin/de/livepoll/api/controller/PollItemController.kt +++ b/src/main/kotlin/de/livepoll/api/controller/PollItemController.kt @@ -22,17 +22,8 @@ class PollItemController( private val accountService: AccountService ) { - //--------------------------------------------- Get ---------------------------------------------------------------- - - @ApiOperation(value = "Get poll item", tags = ["Poll item"]) - @GetMapping("/{id}") - fun getPollItem(@PathVariable(name = "id") pollItemId: Long): PollItemDtoOut { - accountService.checkAuthorizationByPollItemId(pollItemId) - return pollItemService.getPollItem(pollItemId) - } - - //-------------------------------------------- Create -------------------------------------------------------------- + @ApiOperation(value = "Create multiple choice item", tags = ["Poll item"]) @PostMapping("/multiple-choice") fun createMultipleChoiceItem(@RequestBody newItem: MultipleChoiceItemDtoIn): ResponseEntity<*> { @@ -54,8 +45,17 @@ class PollItemController( return ResponseEntity.created(URI(newItem.pollId.toString())).body(addedItem) } + //--------------------------------------------- Get ---------------------------------------------------------------- + + @ApiOperation(value = "Get poll item", tags = ["Poll item"]) + @GetMapping("/{id}") + fun getPollItem(@PathVariable(name = "id") pollItemId: Long): PollItemDtoOut { + accountService.checkAuthorizationByPollItemId(pollItemId) + return pollItemService.getPollItem(pollItemId) + } //-------------------------------------------- Update -------------------------------------------------------------- + @ApiOperation(value = "Update multiple choice item", tags = ["Poll item"]) @PutMapping("/multiple-choice/{pollItemId}") fun updateMultipleChoiceItem(@RequestBody updatedItem: MultipleChoiceItemDtoIn, @PathVariable(name="pollItemId") pollItemId: Long): ResponseEntity<*> { @@ -77,7 +77,6 @@ class PollItemController( return ResponseEntity.ok(pollItemService.updateOpenTextItem(pollItemId, updatedItem)) } - //-------------------------------------------- Delete -------------------------------------------------------------- @ApiOperation(value = "Delete poll item", tags = ["Poll item"]) @@ -87,7 +86,4 @@ class PollItemController( pollItemService.deleteItem(pollItemId) return ResponseEntity.ok("Deleted poll item") } - - //-------------------------------------------- Update -------------------------------------------------------------- - } diff --git a/src/main/kotlin/de/livepoll/api/controller/WebSocketController.kt b/src/main/kotlin/de/livepoll/api/controller/WebSocketController.kt index 6090006d..b2a7b0e1 100644 --- a/src/main/kotlin/de/livepoll/api/controller/WebSocketController.kt +++ b/src/main/kotlin/de/livepoll/api/controller/WebSocketController.kt @@ -12,8 +12,6 @@ class WebSocketController( ) { @MessageMapping("/{pollItemId}") fun processAnswer(@DestinationVariable pollItemId: Long, @Payload answer: String) { - println(answer) - println(pollItemId) webSocketService.saveAnswer(pollItemId, answer) } } \ No newline at end of file diff --git a/src/main/kotlin/de/livepoll/api/service/AccountService.kt b/src/main/kotlin/de/livepoll/api/service/AccountService.kt index bebde98b..54b97299 100644 --- a/src/main/kotlin/de/livepoll/api/service/AccountService.kt +++ b/src/main/kotlin/de/livepoll/api/service/AccountService.kt @@ -52,6 +52,13 @@ class AccountService( } } + fun createPostmanAccount() { + if (userRepository.existsByUsername("postman")) return + val user = User(0, "postman", "noreply@live-poll.de", passwordEncoder.encode("1234"), + true, "ROLE_USER", emptyList()) + userRepository.saveAndFlush(user) + } + fun createVerificationToken(user: User, token: String) { val verificationToken = VerificationToken(0, token, user.username, calculateExpiryDate()) verificationTokenRepository.saveAndFlush(verificationToken) diff --git a/src/main/kotlin/de/livepoll/api/service/WebSocketService.kt b/src/main/kotlin/de/livepoll/api/service/WebSocketService.kt index 7282c852..59808bf3 100644 --- a/src/main/kotlin/de/livepoll/api/service/WebSocketService.kt +++ b/src/main/kotlin/de/livepoll/api/service/WebSocketService.kt @@ -49,10 +49,8 @@ class WebSocketService( } fun saveAnswer(pollItemId: Long, payload: String) { - println("PAYLOAD: $payload") val mapper = ObjectMapper() - val type: String = mapper.readValue(payload, Map::class.java)["type"].toString() - when (type) { + when (mapper.readValue(payload, Map::class.java)["type"].toString()) { // Multiple Choice PollItemType.MULTIPLE_CHOICE.representation -> { val obj: MultipleChoiceItemParticipantAnswerDtoIn = diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9cf52098..fba1a9db 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -21,4 +21,6 @@ server.ssl: enabled: ${LIVE_POLL_HTTPS_ENABLED:true} key-store-type: PKCS12 key-store: classpath:cert/localhost.p12 - key-store-password: ${LIVE_POLL_HTTPS_CERT_PASSWORD} \ No newline at end of file + key-store-password: ${LIVE_POLL_HTTPS_CERT_PASSWORD} + +management.endpoints.web.exposure.include: health \ No newline at end of file From 66e7c7b2519c8d625092a0ebbde525cfeb260e51 Mon Sep 17 00:00:00 2001 From: Niklas-23 <65356992+Niklas-23@users.noreply.github.com> Date: Tue, 18 May 2021 11:58:28 +0200 Subject: [PATCH 12/25] Presentation scheduling and design patterns (#92) * Websocket endpoint for presentation mode Each time a participants sends a new answer, the updated poll item is send to the presenter * Update json key of poll ID from `id` to `pollId` * Update json key of poll ID from `id` to `pollId` * Poll presentation scheduling * Poll item answers composite pattern * Update application-test.yml * Update config * Update Livepoll.postman_collection.json Co-authored-by: Marc Auberer --- media/answers_old.png | Bin 0 -> 31900 bytes media/answers_old.svg | 587 ++++++++++++++++++ media/answers_with_inheritance.png | Bin 0 -> 29214 bytes media/answers_with_inheritance.svg | 537 ++++++++++++++++ pom.xml | 6 + postman/Livepoll.postman_collection.json | 4 +- .../api/config/QuartzSchedulerConfig.kt | 37 ++ .../de/livepoll/api/config/WebSocketConfig.kt | 2 +- .../api/controller/WebSocketController.kt | 1 + .../api/entity/db/MultipleChoiceItemAnswer.kt | 8 +- .../kotlin/de/livepoll/api/entity/db/Poll.kt | 6 +- .../livepoll/api/entity/db/QuizItemAnswer.kt | 8 +- .../api/entity/db/SelectionOptionAnswer.kt | 8 + .../de/livepoll/api/entity/dto/PollDtoIn.kt | 4 +- .../de/livepoll/api/entity/dto/PollDtoOut.kt | 4 +- .../de/livepoll/api/service/PollService.kt | 77 ++- .../livepoll/api/service/WebSocketService.kt | 15 +- .../quartz/AutoWiringSpringBeanJobFactory.kt | 30 + .../api/util/quartz/JobScheduleCrator.kt | 45 ++ .../util/quartz/StartPollPresentationJob.kt | 24 + .../util/quartz/StopPollPresentationJob.kt | 23 + .../api/util/websocket/SubscribeListener.kt | 23 +- src/main/resources/application.yml | 5 + src/main/resources/quartz.properties | 8 + src/main/resources/quartz_tables.sql | 161 +++++ src/test/resources/application-test.yml | 7 +- src/test/resources/quartz.properties | 8 + src/test/resources/quartz_tables.sql | 238 +++++++ 28 files changed, 1841 insertions(+), 35 deletions(-) create mode 100644 media/answers_old.png create mode 100644 media/answers_old.svg create mode 100644 media/answers_with_inheritance.png create mode 100644 media/answers_with_inheritance.svg create mode 100644 src/main/kotlin/de/livepoll/api/config/QuartzSchedulerConfig.kt create mode 100644 src/main/kotlin/de/livepoll/api/entity/db/SelectionOptionAnswer.kt create mode 100644 src/main/kotlin/de/livepoll/api/util/quartz/AutoWiringSpringBeanJobFactory.kt create mode 100644 src/main/kotlin/de/livepoll/api/util/quartz/JobScheduleCrator.kt create mode 100644 src/main/kotlin/de/livepoll/api/util/quartz/StartPollPresentationJob.kt create mode 100644 src/main/kotlin/de/livepoll/api/util/quartz/StopPollPresentationJob.kt create mode 100644 src/main/resources/quartz.properties create mode 100644 src/main/resources/quartz_tables.sql create mode 100644 src/test/resources/quartz.properties create mode 100644 src/test/resources/quartz_tables.sql diff --git a/media/answers_old.png b/media/answers_old.png new file mode 100644 index 0000000000000000000000000000000000000000..5073abf5e519ea9c22900983ebfc5c8dda1c67b3 GIT binary patch literal 31900 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 I2x@=&FX!AMfdBvi literal 0 HcmV?d00001 diff --git a/media/answers_old.svg b/media/answers_old.svg new file mode 100644 index 00000000..a9710b54 --- /dev/null +++ b/media/answers_old.svg @@ -0,0 +1,587 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Unit + + + + + + + + + + + OpenTextItemAnswer() + + + + + + + + + + + + + + OpenTextItemAnswer(Long, OpenTextItem, String) + + + + + + + + + + + + String + + + + + + + + + + + answer + + + + + + Long + + + + + + + + + + + id + + + + + + OpenTextItem + + + + + + + + + + + openTextItem + + + + + + + + + + + + + + + OpenTextItemAnswer + + + + + + + + + + + + Unit + + + + + + + + + + + QuizItemAnswer() + + + + + + + + + + + + + + QuizItemAnswer(Long, QuizItem, String, Boolean, Int) + + + + + + + + + + + + Int + + + + + + + + + + + answerCount + + + + + + Boolean + + + + + + + + + + + correct + + + + + + Long + + + + + + + + + + + id + + + + + + QuizItem + + + + + + + + + + + quizItem + + + + + + String + + + + + + + + + + + selectionOption + + + + + + + + + + + + + + + QuizItemAnswer + + + + + + + + + + + + Unit + + + + + + + + + + + MultipleChoiceItemAnswer() + + + + + + + + + + + + + + MultipleChoiceItemAnswer(Long, MultipleChoiceItem, String, Int) + + + + + + + + + + + + Int + + + + + + + + + + + answerCount + + + + + + Long + + + + + + + + + + + id + + + + + + MultipleChoiceItem + + + + + + + + + + + multipleChoiceItem + + + + + + String + + + + + + + + + + + selectionOption + + + + + + + + + + + + + + + MultipleChoiceItemAnswer + + + diff --git a/media/answers_with_inheritance.png b/media/answers_with_inheritance.png new file mode 100644 index 0000000000000000000000000000000000000000..276df845618a4aa5f97415a805fdc6ad2e8b3d5a GIT binary patch literal 29214 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 p2!Z%|;Hf7FL>3QR{~v#F;u_MtWhQP!GT + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Int + + + + + + + + + + + answerCount + + + + + + Long + + + + + + + + + + + id + + + + + + String + + + + + + + + + + + selectionOption + + + + + + + + + + + + + + + SelectionOptionAnswer + + + + + + + + + + + + Unit + + + + + + + + + + + QuizItemAnswer() + + + + + + + + + + + + + + QuizItemAnswer(Long, QuizItem, String, Boolean, Int) + + + + + + + + + + + + Int + + + + + + + + + + + answerCount + + + + + + Boolean + + + + + + + + + + + correct + + + + + + Long + + + + + + + + + + + id + + + + + + QuizItem + + + + + + + + + + + quizItem + + + + + + String + + + + + + + + + + + selectionOption + + + + + + + + + + + + + + + QuizItemAnswer + + + + + + + + + + + + Unit + + + + + + + + + + + MultipleChoiceItemAnswer() + + + + + + + + + + + + + + MultipleChoiceItemAnswer(Long, MultipleChoiceItem, String, Int) + + + + + + + + + + + + Int + + + + + + + + + + + answerCount + + + + + + Long + + + + + + + + + + + id + + + + + + MultipleChoiceItem + + + + + + + + + + + multipleChoiceItem + + + + + + String + + + + + + + + + + + selectionOption + + + + + + + + + + + + + + + MultipleChoiceItemAnswer + + + + + + + + + + + diff --git a/pom.xml b/pom.xml index 943f3be1..409319fb 100644 --- a/pom.xml +++ b/pom.xml @@ -21,6 +21,12 @@ + + org.springframework.boot + spring-boot-starter-quartz + 2.2.0.RELEASE + + org.springframework.boot spring-boot-starter-websocket diff --git a/postman/Livepoll.postman_collection.json b/postman/Livepoll.postman_collection.json index bd226969..c363a74b 100644 --- a/postman/Livepoll.postman_collection.json +++ b/postman/Livepoll.postman_collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "3db6a2a6-89c4-47ef-8186-f90672f63c9d", + "_postman_id": "65ba7f23-1487-472a-a87c-4af4209e407e", "name": "Livepoll", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, @@ -1188,7 +1188,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"name\": \"{{$randomProductName}}\",\r\n \"startDate\": \"1610705932\",\r\n \"endDate\":\"1610705932\",\r\n \"slug\": \"12345\",\r\n \"currentItem\": {{poll-item-id}}\r\n \r\n}", + "raw": "{\r\n \"name\": \"{{$randomProductName}}\",\r\n \"startDate\": \"1669892400000\",\r\n \"endDate\":\"1669892400000\",\r\n \"slug\": \"12345\",\r\n \"currentItem\": {{poll-item-id}}\r\n \r\n}", "options": { "raw": { "language": "json" diff --git a/src/main/kotlin/de/livepoll/api/config/QuartzSchedulerConfig.kt b/src/main/kotlin/de/livepoll/api/config/QuartzSchedulerConfig.kt new file mode 100644 index 00000000..2430c63e --- /dev/null +++ b/src/main/kotlin/de/livepoll/api/config/QuartzSchedulerConfig.kt @@ -0,0 +1,37 @@ +package de.livepoll.api.config + +import de.livepoll.api.util.quartz.AutoWiringSpringBeanJobFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.ApplicationContext +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.io.ClassPathResource +import org.springframework.scheduling.quartz.* +import javax.sql.DataSource + +@Configuration +class QuartzSchedulerConfig { + + @Autowired + lateinit var applicationContext: ApplicationContext + + @Autowired + lateinit var dataSource: DataSource + + @Bean + fun springBeanJobFactory(): SpringBeanJobFactory { + val jobFactory = AutoWiringSpringBeanJobFactory() + jobFactory.setApplicationContext(applicationContext) + return jobFactory + } + + @Bean + fun scheduler(): SchedulerFactoryBean { + val schedulerFactory = SchedulerFactoryBean() + schedulerFactory.setConfigLocation(ClassPathResource("quartz.properties")) + schedulerFactory.setJobFactory(springBeanJobFactory()) + schedulerFactory.setDataSource(dataSource) + + return schedulerFactory + } +} \ No newline at end of file diff --git a/src/main/kotlin/de/livepoll/api/config/WebSocketConfig.kt b/src/main/kotlin/de/livepoll/api/config/WebSocketConfig.kt index 4995ddb2..cc51e090 100644 --- a/src/main/kotlin/de/livepoll/api/config/WebSocketConfig.kt +++ b/src/main/kotlin/de/livepoll/api/config/WebSocketConfig.kt @@ -16,7 +16,7 @@ class WebSocketConfig( ) : WebSocketMessageBrokerConfigurer { override fun configureMessageBroker(registry: MessageBrokerRegistry) { - registry.enableSimpleBroker("/v1/websocket/poll") + registry.enableSimpleBroker("/v1/websocket/poll", "/v1/websocket/presentation") registry.setApplicationDestinationPrefixes("/v1/websocket/answer") } diff --git a/src/main/kotlin/de/livepoll/api/controller/WebSocketController.kt b/src/main/kotlin/de/livepoll/api/controller/WebSocketController.kt index b2a7b0e1..b8a47217 100644 --- a/src/main/kotlin/de/livepoll/api/controller/WebSocketController.kt +++ b/src/main/kotlin/de/livepoll/api/controller/WebSocketController.kt @@ -13,5 +13,6 @@ class WebSocketController( @MessageMapping("/{pollItemId}") fun processAnswer(@DestinationVariable pollItemId: Long, @Payload answer: String) { webSocketService.saveAnswer(pollItemId, answer) + webSocketService.sendItemWithAnswers(pollItemId) } } \ No newline at end of file diff --git a/src/main/kotlin/de/livepoll/api/entity/db/MultipleChoiceItemAnswer.kt b/src/main/kotlin/de/livepoll/api/entity/db/MultipleChoiceItemAnswer.kt index 84eb02d2..1280cf62 100644 --- a/src/main/kotlin/de/livepoll/api/entity/db/MultipleChoiceItemAnswer.kt +++ b/src/main/kotlin/de/livepoll/api/entity/db/MultipleChoiceItemAnswer.kt @@ -12,7 +12,7 @@ class MultipleChoiceItemAnswer ( @GeneratedValue(strategy = GenerationType.IDENTITY) @NonNull @Column - val id: Long, + override val id: Long, @JsonIgnore @NonNull @@ -21,9 +21,9 @@ class MultipleChoiceItemAnswer ( var multipleChoiceItem: MultipleChoiceItem, @Column(name = "selection_option") - val selectionOption: String, + override val selectionOption: String, @Column(name = "answer_count") - var answerCount: Int + override var answerCount: Int -) +) : SelectionOptionAnswer diff --git a/src/main/kotlin/de/livepoll/api/entity/db/Poll.kt b/src/main/kotlin/de/livepoll/api/entity/db/Poll.kt index a261184d..76814f4f 100644 --- a/src/main/kotlin/de/livepoll/api/entity/db/Poll.kt +++ b/src/main/kotlin/de/livepoll/api/entity/db/Poll.kt @@ -20,9 +20,11 @@ data class Poll( @Column(nullable = false) var name: String, - var startDate: Date, + @Column(nullable = true) + var startDate: Date?, - var endDate: Date, + @Column(nullable = true) + var endDate: Date?, var slug: String, diff --git a/src/main/kotlin/de/livepoll/api/entity/db/QuizItemAnswer.kt b/src/main/kotlin/de/livepoll/api/entity/db/QuizItemAnswer.kt index 044dfeba..918262d9 100644 --- a/src/main/kotlin/de/livepoll/api/entity/db/QuizItemAnswer.kt +++ b/src/main/kotlin/de/livepoll/api/entity/db/QuizItemAnswer.kt @@ -12,7 +12,7 @@ class QuizItemAnswer( @GeneratedValue(strategy = GenerationType.IDENTITY) @NonNull @Column - val id: Long, + override val id: Long, @JsonIgnore @NonNull @@ -21,12 +21,12 @@ class QuizItemAnswer( val quizItem: QuizItem, @Column(name = "selection_option") - val selectionOption: String, + override val selectionOption: String, @Column(name = "is_correct") var isCorrect: Boolean, @Column(name = "answer_count") - var answerCount: Int + override var answerCount: Int -) +) : SelectionOptionAnswer diff --git a/src/main/kotlin/de/livepoll/api/entity/db/SelectionOptionAnswer.kt b/src/main/kotlin/de/livepoll/api/entity/db/SelectionOptionAnswer.kt new file mode 100644 index 00000000..2ba8e6bc --- /dev/null +++ b/src/main/kotlin/de/livepoll/api/entity/db/SelectionOptionAnswer.kt @@ -0,0 +1,8 @@ +package de.livepoll.api.entity.db + +interface SelectionOptionAnswer{ + val id: Long + var answerCount: Int + val selectionOption: String + +} \ No newline at end of file diff --git a/src/main/kotlin/de/livepoll/api/entity/dto/PollDtoIn.kt b/src/main/kotlin/de/livepoll/api/entity/dto/PollDtoIn.kt index 9443ed56..2814a0f3 100644 --- a/src/main/kotlin/de/livepoll/api/entity/dto/PollDtoIn.kt +++ b/src/main/kotlin/de/livepoll/api/entity/dto/PollDtoIn.kt @@ -4,8 +4,8 @@ import java.util.* data class PollDtoIn( val name: String, - val startDate: Date, - val endDate: Date, + val startDate: Date?, + val endDate: Date?, val slug: String?, val currentItem: Long? ) diff --git a/src/main/kotlin/de/livepoll/api/entity/dto/PollDtoOut.kt b/src/main/kotlin/de/livepoll/api/entity/dto/PollDtoOut.kt index 1e7e785f..785b20ea 100644 --- a/src/main/kotlin/de/livepoll/api/entity/dto/PollDtoOut.kt +++ b/src/main/kotlin/de/livepoll/api/entity/dto/PollDtoOut.kt @@ -5,8 +5,8 @@ import java.util.* data class PollDtoOut( val id: Long, val name: String, - val startDate: Date, - val endDate: Date, + val startDate: Date?, + val endDate: Date?, val slug: String, var currentItem: Long? ) diff --git a/src/main/kotlin/de/livepoll/api/service/PollService.kt b/src/main/kotlin/de/livepoll/api/service/PollService.kt index ae52792d..685df675 100644 --- a/src/main/kotlin/de/livepoll/api/service/PollService.kt +++ b/src/main/kotlin/de/livepoll/api/service/PollService.kt @@ -7,21 +7,31 @@ import de.livepoll.api.entity.dto.PollDtoOut import de.livepoll.api.entity.dto.PollItemDtoOut import de.livepoll.api.repository.PollRepository import de.livepoll.api.repository.UserRepository +import de.livepoll.api.util.quartz.JobScheduleCrator +import de.livepoll.api.util.quartz.StartPollPresentationJob +import de.livepoll.api.util.quartz.StopPollPresentationJob import de.livepoll.api.util.toDtoOut +import org.quartz.JobKey import org.springframework.dao.EmptyResultDataAccessException import org.springframework.http.HttpStatus +import org.springframework.scheduling.quartz.SchedulerFactoryBean import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional import org.springframework.web.server.ResponseStatusException import java.util.* + @Service class PollService( private val userRepository: UserRepository, private val pollRepository: PollRepository, private val pollItemService: PollItemService, - private val webSocketService: WebSocketService + private val webSocketService: WebSocketService, + private val schedulerFactory: SchedulerFactoryBean, + private val jobScheduleCrator: JobScheduleCrator ) { + //--------------------------------------------- Get ---------------------------------------------------------------- fun getPoll(pollId: Long): PollDtoOut { @@ -65,7 +75,21 @@ class PollService( null, emptyList().toMutableList() ) - return pollRepository.saveAndFlush(poll).toDtoOut() + val pollFromDb = pollRepository.saveAndFlush(poll) + try { + if (pollFromDb.startDate != null && pollFromDb.endDate != null) { + schedulePoll(pollFromDb.id, pollFromDb.startDate!!, pollFromDb.endDate!!) + return pollFromDb.toDtoOut() + } else { + pollFromDb.startDate = null + pollFromDb.endDate = null + return pollRepository.saveAndFlush(pollFromDb).toDtoOut() + } + } catch (ex: ResponseStatusException) { + pollFromDb.startDate = null + pollFromDb.endDate = null + return pollRepository.saveAndFlush(pollFromDb).toDtoOut() + } } } @@ -88,8 +112,15 @@ class PollService( ResponseStatusException(HttpStatus.NOT_FOUND, "This poll does not exist") }.run { this.name = poll.name - this.startDate = poll.startDate - this.endDate = poll.endDate + if (poll.startDate != null && poll.endDate != null) { + schedulePoll(pollId, poll.startDate, poll.endDate) + this.startDate = poll.startDate + this.endDate = poll.endDate + } else if (poll.startDate == null && poll.endDate == null) { + stopScheduledPoll(pollId) + this.startDate = null + this.endDate = null + } if (poll.slug != null && isSlugUnique(poll.slug)) { this.slug = poll.slug } @@ -100,4 +131,42 @@ class PollService( } fun isSlugUnique(slug: String) = pollRepository.findBySlug(slug) == null + + private fun schedulePoll(pollId: Long, startDate: Date, stopDate: Date) { + if (startDate.before(GregorianCalendar.getInstance().time) || stopDate.before(GregorianCalendar.getInstance().time)) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Poll was not planned because start or end date is in the past") + } else { + val jobDetailStart = jobScheduleCrator.createJob(StartPollPresentationJob::class.java, "start-poll-" + pollId.toString(), pollId) + val triggerStart = jobScheduleCrator.createSimpleTrigger("start-poll-trigger-" + pollId.toString(), startDate) + schedulerFactory.`object`!!.scheduleJob(jobDetailStart, triggerStart) + + val jobDetailStop = jobScheduleCrator.createJob(StopPollPresentationJob::class.java, "stop-poll-" + pollId.toString(), pollId) + val triggerStop = jobScheduleCrator.createSimpleTrigger("stop-poll-trigger-" + pollId.toString(), stopDate) + schedulerFactory.`object`!!.scheduleJob(jobDetailStop, triggerStop) + } + + } + + @Transactional + fun executeStartPoll(pollId: Long) { + pollRepository.findById(pollId).ifPresent { + it.currentItem = it.pollItems[0].id + webSocketService.sendCurrentItem(it.slug, it.id, it.currentItem) + pollRepository.saveAndFlush(it) + } + } + + fun executeStopPoll(pollId: Long) { + pollRepository.findById(pollId).ifPresent { + it.currentItem = null + webSocketService.sendCurrentItem(it.slug, it.id, null) + pollRepository.saveAndFlush(it) + } + } + + fun stopScheduledPoll(pollId: Long) { + schedulerFactory.`object`!!.deleteJob(JobKey("start-poll-" + pollId)) + schedulerFactory.`object`!!.deleteJob(JobKey("stop-poll-" + pollId)) + } + } diff --git a/src/main/kotlin/de/livepoll/api/service/WebSocketService.kt b/src/main/kotlin/de/livepoll/api/service/WebSocketService.kt index 59808bf3..facd4476 100644 --- a/src/main/kotlin/de/livepoll/api/service/WebSocketService.kt +++ b/src/main/kotlin/de/livepoll/api/service/WebSocketService.kt @@ -16,6 +16,7 @@ import org.springframework.http.HttpStatus import org.springframework.messaging.simp.SimpMessageSendingOperations import org.springframework.messaging.simp.user.SimpUserRegistry import org.springframework.stereotype.Controller +import org.springframework.transaction.annotation.Transactional import org.springframework.web.server.ResponseStatusException @@ -29,9 +30,10 @@ class WebSocketService( private val quizItemAnswerRepository: QuizItemAnswerRepository, private val openTextItemRepository: OpenTextItemRepository ) { + private val websocketPrefix = "/v1/websocket" fun sendCurrentItem(slug: String, pollId: Long, currentItemId: Long?) { - val url = "/v1/websocket/poll/$slug" + val url = "${websocketPrefix}/poll/$slug" if (currentItemId != null) { val item: PollItemDtoOut = pollItemService.getPollItem(currentItemId) if (pollId == item.pollId) { @@ -43,7 +45,7 @@ class WebSocketService( } } else { simpUserRegistry.users.forEach { - messagingTemplate.convertAndSendToUser(it.name, url, "{\"id\":$pollId}") + messagingTemplate.convertAndSendToUser(it.name, url, "{\"pollId\":$pollId}") } } } @@ -79,4 +81,13 @@ class WebSocketService( } } + @Transactional + fun sendItemWithAnswers(itemId: Long){ + val item: PollItemDtoOut = pollItemService.getPollItem(itemId) + val url = "$websocketPrefix/presentation/${item.pollId}" + simpUserRegistry.users.forEach { + messagingTemplate.convertAndSendToUser(it.name, url, item) + } + } + } diff --git a/src/main/kotlin/de/livepoll/api/util/quartz/AutoWiringSpringBeanJobFactory.kt b/src/main/kotlin/de/livepoll/api/util/quartz/AutoWiringSpringBeanJobFactory.kt new file mode 100644 index 00000000..b11ca288 --- /dev/null +++ b/src/main/kotlin/de/livepoll/api/util/quartz/AutoWiringSpringBeanJobFactory.kt @@ -0,0 +1,30 @@ +package de.livepoll.api.util.quartz + +import org.quartz.spi.TriggerFiredBundle +import org.springframework.beans.BeansException +import org.springframework.beans.factory.config.AutowireCapableBeanFactory +import org.springframework.context.ApplicationContext +import org.springframework.context.ApplicationContextAware +import org.springframework.scheduling.quartz.SpringBeanJobFactory + + +/** + * Adds auto-wiring support to quartz jobs. + * @see "https://gist.github.com/jelies/5085593" + */ +class AutoWiringSpringBeanJobFactory : SpringBeanJobFactory(), ApplicationContextAware { + @Transient + private var beanFactory: AutowireCapableBeanFactory? = null + + @Throws(BeansException::class) + override fun setApplicationContext(applicationContext: ApplicationContext) { + beanFactory = applicationContext.autowireCapableBeanFactory + } + + @Throws(Exception::class) + override fun createJobInstance(bundle: TriggerFiredBundle): Any { + val job = super.createJobInstance(bundle) + beanFactory!!.autowireBean(job) + return job + } +} \ No newline at end of file diff --git a/src/main/kotlin/de/livepoll/api/util/quartz/JobScheduleCrator.kt b/src/main/kotlin/de/livepoll/api/util/quartz/JobScheduleCrator.kt new file mode 100644 index 00000000..bb5a8a82 --- /dev/null +++ b/src/main/kotlin/de/livepoll/api/util/quartz/JobScheduleCrator.kt @@ -0,0 +1,45 @@ +package de.livepoll.api.util.quartz + +import org.quartz.Job +import org.quartz.JobDataMap +import org.quartz.JobDetail +import org.quartz.SimpleTrigger +import org.springframework.context.ApplicationContext +import org.springframework.scheduling.quartz.JobDetailFactoryBean +import org.springframework.scheduling.quartz.SimpleTriggerFactoryBean +import org.springframework.stereotype.Component +import java.util.* + + +@Component +class JobScheduleCrator( + private val applicationContext: ApplicationContext +) { + + fun createJob(jobClass: Class, jobName: String, pollId: Long): JobDetail { + + val factoryBean = JobDetailFactoryBean() + factoryBean.setJobClass(jobClass) + factoryBean.setDurability(true) + factoryBean.setApplicationContext(applicationContext) + factoryBean.setName(jobName) + val jobDataMap = JobDataMap() + jobDataMap["pollId"] = pollId + factoryBean.jobDataMap = jobDataMap + factoryBean.afterPropertiesSet() + + return factoryBean.getObject()!! + } + + fun createSimpleTrigger(triggerName: String, startTime: Date): SimpleTrigger { + + val factoryBean = SimpleTriggerFactoryBean() + factoryBean.setName(triggerName) + factoryBean.setStartTime(startTime) + factoryBean.setRepeatCount(0) + factoryBean.setRepeatInterval(1) + factoryBean.afterPropertiesSet() + + return factoryBean.getObject()!! + } +} \ No newline at end of file diff --git a/src/main/kotlin/de/livepoll/api/util/quartz/StartPollPresentationJob.kt b/src/main/kotlin/de/livepoll/api/util/quartz/StartPollPresentationJob.kt new file mode 100644 index 00000000..1533724b --- /dev/null +++ b/src/main/kotlin/de/livepoll/api/util/quartz/StartPollPresentationJob.kt @@ -0,0 +1,24 @@ +package de.livepoll.api.util.quartz + +import de.livepoll.api.repository.PollRepository +import de.livepoll.api.service.PollService +import de.livepoll.api.service.WebSocketService +import org.quartz.Job +import org.quartz.JobExecutionContext +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +class StartPollPresentationJob : Job { + + @Autowired + private lateinit var pollService: PollService + + @Transactional + override fun execute(context: JobExecutionContext) { + println("Execute start poll event with poll-id: " + context.jobDetail.jobDataMap["pollId"].toString()) + pollService.executeStartPoll(context.jobDetail.jobDataMap["pollId"].toString().toLong()) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/de/livepoll/api/util/quartz/StopPollPresentationJob.kt b/src/main/kotlin/de/livepoll/api/util/quartz/StopPollPresentationJob.kt new file mode 100644 index 00000000..76f36aa9 --- /dev/null +++ b/src/main/kotlin/de/livepoll/api/util/quartz/StopPollPresentationJob.kt @@ -0,0 +1,23 @@ +package de.livepoll.api.util.quartz + +import de.livepoll.api.repository.PollRepository +import de.livepoll.api.service.PollService +import de.livepoll.api.service.WebSocketService +import org.quartz.Job +import org.quartz.JobExecutionContext +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +class StopPollPresentationJob : Job { + + @Autowired + private lateinit var pollService: PollService + + @Transactional + override fun execute(context: JobExecutionContext) { + println("Execute stop poll event with poll-id: " + context.jobDetail.jobDataMap["pollId"].toString()) + pollService.executeStopPoll(context.jobDetail.jobDataMap["pollId"].toString().toLong()) + } +} \ No newline at end of file diff --git a/src/main/kotlin/de/livepoll/api/util/websocket/SubscribeListener.kt b/src/main/kotlin/de/livepoll/api/util/websocket/SubscribeListener.kt index a6cb7ed2..85203ac7 100644 --- a/src/main/kotlin/de/livepoll/api/util/websocket/SubscribeListener.kt +++ b/src/main/kotlin/de/livepoll/api/util/websocket/SubscribeListener.kt @@ -19,19 +19,20 @@ class SubscribeListener( @Transactional override fun onApplicationEvent(event: SessionSubscribeEvent) { - val slug = event.message.headers["simpDestination"].toString().split("/").last() - val poll = pollRepository.findBySlug(slug) - if (poll == null) { - throw ResponseStatusException(HttpStatus.NOT_FOUND) - } else { - val url = "/v1/websocket/poll/$slug" - if (poll.currentItem == null) { - messagingTemplate.convertAndSendToUser(event.user!!.name, url, "{\"id\":${poll.id}}") + if( event.message.headers["simpDestination"].toString().contains("poll")){ + val slug = event.message.headers["simpDestination"].toString().split("/").last() + val poll = pollRepository.findBySlug(slug) + if (poll == null) { + throw ResponseStatusException(HttpStatus.NOT_FOUND) } else { - val pollItemDto = pollItemService.getPollItem(poll.currentItem!!) - messagingTemplate.convertAndSendToUser(event.user!!.name, url, pollItemDto) + val url = "/v1/websocket/poll/$slug" + if (poll.currentItem == null) { + messagingTemplate.convertAndSendToUser(event.user!!.name, url, "{\"pollId\":${poll.id}}") + } else { + val pollItemDto = pollItemService.getPollItem(poll.currentItem!!) + messagingTemplate.convertAndSendToUser(event.user!!.name, url, pollItemDto) + } } } } - } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fba1a9db..73a7c153 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -16,6 +16,11 @@ spring: show-sql: true hibernate.ddl-auto: update properties.hibernate.dialect: org.hibernate.dialect.MySQL8Dialect + quartz: + job-store-type: jdbc + jdbc: + initialize-schema: always + schema: classpath:quartz_tables.sql server.ssl: enabled: ${LIVE_POLL_HTTPS_ENABLED:true} diff --git a/src/main/resources/quartz.properties b/src/main/resources/quartz.properties new file mode 100644 index 00000000..1a1e4b6a --- /dev/null +++ b/src/main/resources/quartz.properties @@ -0,0 +1,8 @@ +# Thread-pool +org.quartz.threadPool.class=org.quartz.simpl.SimpleThreadPool +org.quartz.threadPool.threadCount=2 +org.quartz.threadPool.threadsInheritContextClassLoaderOfInitializingThread=true + +# JDBCJobStore using JobStoreTX +org.quartz.jobStore.class=org.quartz.impl.jdbcjobstore.JobStoreTX +org.quartz.jobStore.driverDelegateClass=org.quartz.impl.jdbcjobstore.StdJDBCDelegate diff --git a/src/main/resources/quartz_tables.sql b/src/main/resources/quartz_tables.sql new file mode 100644 index 00000000..fa460824 --- /dev/null +++ b/src/main/resources/quartz_tables.sql @@ -0,0 +1,161 @@ + + +CREATE TABLE IF NOT EXISTS QRTZ_FIRED_TRIGGERS; +CREATE TABLE IF NOT EXISTS QRTZ_PAUSED_TRIGGER_GRPS; +CREATE TABLE IF NOT EXISTS QRTZ_SCHEDULER_STATE; +CREATE TABLE IF NOT EXISTS QRTZ_LOCKS; +CREATE TABLE IF NOT EXISTS QRTZ_SIMPLE_TRIGGERS; +CREATE TABLE IF NOT EXISTS QRTZ_SIMPROP_TRIGGERS; +CREATE TABLE IF NOT EXISTS QRTZ_CRON_TRIGGERS; +CREATE TABLE IF NOT EXISTS QRTZ_BLOB_TRIGGERS; +CREATE TABLE IF NOT EXISTS QRTZ_TRIGGERS; +CREATE TABLE IF NOT EXISTS QRTZ_JOB_DETAILS; +CREATE TABLE IF NOT EXISTS QRTZ_CALENDARS; + + +CREATE TABLE QRTZ_JOB_DETAILS + ( + SCHED_NAME VARCHAR(120) NOT NULL, + JOB_NAME VARCHAR(200) NOT NULL, + JOB_GROUP VARCHAR(200) NOT NULL, + DESCRIPTION VARCHAR(250) NULL, + JOB_CLASS_NAME VARCHAR(250) NOT NULL, + IS_DURABLE VARCHAR(1) NOT NULL, + IS_NONCONCURRENT VARCHAR(1) NOT NULL, + IS_UPDATE_DATA VARCHAR(1) NOT NULL, + REQUESTS_RECOVERY VARCHAR(1) NOT NULL, + JOB_DATA BLOB NULL, + PRIMARY KEY (SCHED_NAME,JOB_NAME,JOB_GROUP) +); + +CREATE TABLE QRTZ_TRIGGERS + ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR(200) NOT NULL, + TRIGGER_GROUP VARCHAR(200) NOT NULL, + JOB_NAME VARCHAR(200) NOT NULL, + JOB_GROUP VARCHAR(200) NOT NULL, + DESCRIPTION VARCHAR(250) NULL, + NEXT_FIRE_TIME BIGINT(13) NULL, + PREV_FIRE_TIME BIGINT(13) NULL, + PRIORITY INTEGER NULL, + TRIGGER_STATE VARCHAR(16) NOT NULL, + TRIGGER_TYPE VARCHAR(8) NOT NULL, + START_TIME BIGINT(13) NOT NULL, + END_TIME BIGINT(13) NULL, + CALENDAR_NAME VARCHAR(200) NULL, + MISFIRE_INSTR SMALLINT(2) NULL, + JOB_DATA BLOB NULL, + PRIMARY KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP), + FOREIGN KEY (SCHED_NAME,JOB_NAME,JOB_GROUP) + REFERENCES QRTZ_JOB_DETAILS(SCHED_NAME,JOB_NAME,JOB_GROUP) +); + +CREATE TABLE QRTZ_SIMPLE_TRIGGERS + ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR(200) NOT NULL, + TRIGGER_GROUP VARCHAR(200) NOT NULL, + REPEAT_COUNT BIGINT(7) NOT NULL, + REPEAT_INTERVAL BIGINT(12) NOT NULL, + TIMES_TRIGGERED BIGINT(10) NOT NULL, + PRIMARY KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP), + FOREIGN KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP) + REFERENCES QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP) +); + +CREATE TABLE QRTZ_CRON_TRIGGERS + ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR(200) NOT NULL, + TRIGGER_GROUP VARCHAR(200) NOT NULL, + CRON_EXPRESSION VARCHAR(200) NOT NULL, + TIME_ZONE_ID VARCHAR(80), + PRIMARY KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP), + FOREIGN KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP) + REFERENCES QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP) +); + +CREATE TABLE QRTZ_SIMPROP_TRIGGERS + ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR(200) NOT NULL, + TRIGGER_GROUP VARCHAR(200) NOT NULL, + STR_PROP_1 VARCHAR(512) NULL, + STR_PROP_2 VARCHAR(512) NULL, + STR_PROP_3 VARCHAR(512) NULL, + INT_PROP_1 INT NULL, + INT_PROP_2 INT NULL, + LONG_PROP_1 BIGINT NULL, + LONG_PROP_2 BIGINT NULL, + DEC_PROP_1 NUMERIC(13,4) NULL, + DEC_PROP_2 NUMERIC(13,4) NULL, + BOOL_PROP_1 VARCHAR(1) NULL, + BOOL_PROP_2 VARCHAR(1) NULL, + PRIMARY KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP), + FOREIGN KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP) + REFERENCES QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP) +); + +CREATE TABLE QRTZ_BLOB_TRIGGERS + ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR(200) NOT NULL, + TRIGGER_GROUP VARCHAR(200) NOT NULL, + BLOB_DATA BLOB NULL, + PRIMARY KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP), + FOREIGN KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP) + REFERENCES QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP) +); + +CREATE TABLE QRTZ_CALENDARS + ( + SCHED_NAME VARCHAR(120) NOT NULL, + CALENDAR_NAME VARCHAR(200) NOT NULL, + CALENDAR BLOB NOT NULL, + PRIMARY KEY (SCHED_NAME,CALENDAR_NAME) +); + +CREATE TABLE QRTZ_PAUSED_TRIGGER_GRPS + ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_GROUP VARCHAR(200) NOT NULL, + PRIMARY KEY (SCHED_NAME,TRIGGER_GROUP) +); + +CREATE TABLE QRTZ_FIRED_TRIGGERS + ( + SCHED_NAME VARCHAR(120) NOT NULL, + ENTRY_ID VARCHAR(95) NOT NULL, + TRIGGER_NAME VARCHAR(200) NOT NULL, + TRIGGER_GROUP VARCHAR(200) NOT NULL, + INSTANCE_NAME VARCHAR(200) NOT NULL, + FIRED_TIME BIGINT(13) NOT NULL, + SCHED_TIME BIGINT(13) NOT NULL, + PRIORITY INTEGER NOT NULL, + STATE VARCHAR(16) NOT NULL, + JOB_NAME VARCHAR(200) NULL, + JOB_GROUP VARCHAR(200) NULL, + IS_NONCONCURRENT VARCHAR(1) NULL, + REQUESTS_RECOVERY VARCHAR(1) NULL, + PRIMARY KEY (SCHED_NAME,ENTRY_ID) +); + +CREATE TABLE QRTZ_SCHEDULER_STATE + ( + SCHED_NAME VARCHAR(120) NOT NULL, + INSTANCE_NAME VARCHAR(200) NOT NULL, + LAST_CHECKIN_TIME BIGINT(13) NOT NULL, + CHECKIN_INTERVAL BIGINT(13) NOT NULL, + PRIMARY KEY (SCHED_NAME,INSTANCE_NAME) +); + +CREATE TABLE QRTZ_LOCKS + ( + SCHED_NAME VARCHAR(120) NOT NULL, + LOCK_NAME VARCHAR(40) NOT NULL, + PRIMARY KEY (SCHED_NAME,LOCK_NAME) +); + + +commit; \ No newline at end of file diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 7d941f7a..d4c6a7c0 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -11,4 +11,9 @@ spring: jpa: show-sql: true hibernate.ddl-auto: create-drop - properties.hibernate.dialect: org.hibernate.dialect.H2Dialect \ No newline at end of file + properties.hibernate.dialect: org.hibernate.dialect.H2Dialect + quartz: + job-store-type: jdbc + jdbc: + initialize-schema: always + schema: classpath:quartz_tables.sql \ No newline at end of file diff --git a/src/test/resources/quartz.properties b/src/test/resources/quartz.properties new file mode 100644 index 00000000..1a1e4b6a --- /dev/null +++ b/src/test/resources/quartz.properties @@ -0,0 +1,8 @@ +# Thread-pool +org.quartz.threadPool.class=org.quartz.simpl.SimpleThreadPool +org.quartz.threadPool.threadCount=2 +org.quartz.threadPool.threadsInheritContextClassLoaderOfInitializingThread=true + +# JDBCJobStore using JobStoreTX +org.quartz.jobStore.class=org.quartz.impl.jdbcjobstore.JobStoreTX +org.quartz.jobStore.driverDelegateClass=org.quartz.impl.jdbcjobstore.StdJDBCDelegate diff --git a/src/test/resources/quartz_tables.sql b/src/test/resources/quartz_tables.sql new file mode 100644 index 00000000..87964d73 --- /dev/null +++ b/src/test/resources/quartz_tables.sql @@ -0,0 +1,238 @@ +CREATE TABLE QRTZ_CALENDARS ( + SCHED_NAME VARCHAR(120) NOT NULL, + CALENDAR_NAME VARCHAR (200) NOT NULL , + CALENDAR IMAGE NOT NULL +); + +CREATE TABLE QRTZ_CRON_TRIGGERS ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR (200) NOT NULL , + TRIGGER_GROUP VARCHAR (200) NOT NULL , + CRON_EXPRESSION VARCHAR (120) NOT NULL , + TIME_ZONE_ID VARCHAR (80) +); + +CREATE TABLE QRTZ_FIRED_TRIGGERS ( + SCHED_NAME VARCHAR(120) NOT NULL, + ENTRY_ID VARCHAR (95) NOT NULL , + TRIGGER_NAME VARCHAR (200) NOT NULL , + TRIGGER_GROUP VARCHAR (200) NOT NULL , + INSTANCE_NAME VARCHAR (200) NOT NULL , + FIRED_TIME BIGINT NOT NULL , + SCHED_TIME BIGINT NOT NULL , + PRIORITY INTEGER NOT NULL , + STATE VARCHAR (16) NOT NULL, + JOB_NAME VARCHAR (200) NULL , + JOB_GROUP VARCHAR (200) NULL , + IS_NONCONCURRENT BOOLEAN NULL , + REQUESTS_RECOVERY BOOLEAN NULL +); + +CREATE TABLE QRTZ_PAUSED_TRIGGER_GRPS ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_GROUP VARCHAR (200) NOT NULL +); + +CREATE TABLE QRTZ_SCHEDULER_STATE ( + SCHED_NAME VARCHAR(120) NOT NULL, + INSTANCE_NAME VARCHAR (200) NOT NULL , + LAST_CHECKIN_TIME BIGINT NOT NULL , + CHECKIN_INTERVAL BIGINT NOT NULL +); + +CREATE TABLE QRTZ_LOCKS ( + SCHED_NAME VARCHAR(120) NOT NULL, + LOCK_NAME VARCHAR (40) NOT NULL +); + +CREATE TABLE QRTZ_JOB_DETAILS ( + SCHED_NAME VARCHAR(120) NOT NULL, + JOB_NAME VARCHAR (200) NOT NULL , + JOB_GROUP VARCHAR (200) NOT NULL , + DESCRIPTION VARCHAR (250) NULL , + JOB_CLASS_NAME VARCHAR (250) NOT NULL , + IS_DURABLE BOOLEAN NOT NULL , + IS_NONCONCURRENT BOOLEAN NOT NULL , + IS_UPDATE_DATA BOOLEAN NOT NULL , + REQUESTS_RECOVERY BOOLEAN NOT NULL , + JOB_DATA IMAGE NULL +); + +CREATE TABLE QRTZ_SIMPLE_TRIGGERS ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR (200) NOT NULL , + TRIGGER_GROUP VARCHAR (200) NOT NULL , + REPEAT_COUNT BIGINT NOT NULL , + REPEAT_INTERVAL BIGINT NOT NULL , + TIMES_TRIGGERED BIGINT NOT NULL +); + +CREATE TABLE QRTZ_SIMPROP_TRIGGERS ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR(200) NOT NULL, + TRIGGER_GROUP VARCHAR(200) NOT NULL, + STR_PROP_1 VARCHAR(512) NULL, + STR_PROP_2 VARCHAR(512) NULL, + STR_PROP_3 VARCHAR(512) NULL, + INT_PROP_1 INTEGER NULL, + INT_PROP_2 INTEGER NULL, + LONG_PROP_1 BIGINT NULL, + LONG_PROP_2 BIGINT NULL, + DEC_PROP_1 NUMERIC(13,4) NULL, + DEC_PROP_2 NUMERIC(13,4) NULL, + BOOL_PROP_1 BOOLEAN NULL, + BOOL_PROP_2 BOOLEAN NULL +); + +CREATE TABLE QRTZ_BLOB_TRIGGERS ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR (200) NOT NULL , + TRIGGER_GROUP VARCHAR (200) NOT NULL , + BLOB_DATA IMAGE NULL +); + +CREATE TABLE QRTZ_TRIGGERS ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR (200) NOT NULL , + TRIGGER_GROUP VARCHAR (200) NOT NULL , + JOB_NAME VARCHAR (200) NOT NULL , + JOB_GROUP VARCHAR (200) NOT NULL , + DESCRIPTION VARCHAR (250) NULL , + NEXT_FIRE_TIME BIGINT NULL , + PREV_FIRE_TIME BIGINT NULL , + PRIORITY INTEGER NULL , + TRIGGER_STATE VARCHAR (16) NOT NULL , + TRIGGER_TYPE VARCHAR (8) NOT NULL , + START_TIME BIGINT NOT NULL , + END_TIME BIGINT NULL , + CALENDAR_NAME VARCHAR (200) NULL , + MISFIRE_INSTR SMALLINT NULL , + JOB_DATA IMAGE NULL +); + +ALTER TABLE QRTZ_CALENDARS ADD + CONSTRAINT PK_QRTZ_CALENDARS PRIMARY KEY + ( + SCHED_NAME, + CALENDAR_NAME + ); + +ALTER TABLE QRTZ_CRON_TRIGGERS ADD + CONSTRAINT PK_QRTZ_CRON_TRIGGERS PRIMARY KEY + ( + SCHED_NAME, + TRIGGER_NAME, + TRIGGER_GROUP + ); + +ALTER TABLE QRTZ_FIRED_TRIGGERS ADD + CONSTRAINT PK_QRTZ_FIRED_TRIGGERS PRIMARY KEY + ( + SCHED_NAME, + ENTRY_ID + ); + +ALTER TABLE QRTZ_PAUSED_TRIGGER_GRPS ADD + CONSTRAINT PK_QRTZ_PAUSED_TRIGGER_GRPS PRIMARY KEY + ( + SCHED_NAME, + TRIGGER_GROUP + ); + +ALTER TABLE QRTZ_SCHEDULER_STATE ADD + CONSTRAINT PK_QRTZ_SCHEDULER_STATE PRIMARY KEY + ( + SCHED_NAME, + INSTANCE_NAME + ); + +ALTER TABLE QRTZ_LOCKS ADD + CONSTRAINT PK_QRTZ_LOCKS PRIMARY KEY + ( + SCHED_NAME, + LOCK_NAME + ); + +ALTER TABLE QRTZ_JOB_DETAILS ADD + CONSTRAINT PK_QRTZ_JOB_DETAILS PRIMARY KEY + ( + SCHED_NAME, + JOB_NAME, + JOB_GROUP + ); + +ALTER TABLE QRTZ_SIMPLE_TRIGGERS ADD + CONSTRAINT PK_QRTZ_SIMPLE_TRIGGERS PRIMARY KEY + ( + SCHED_NAME, + TRIGGER_NAME, + TRIGGER_GROUP + ); + +ALTER TABLE QRTZ_SIMPROP_TRIGGERS ADD + CONSTRAINT PK_QRTZ_SIMPROP_TRIGGERS PRIMARY KEY + ( + SCHED_NAME, + TRIGGER_NAME, + TRIGGER_GROUP + ); + +ALTER TABLE QRTZ_TRIGGERS ADD + CONSTRAINT PK_QRTZ_TRIGGERS PRIMARY KEY + ( + SCHED_NAME, + TRIGGER_NAME, + TRIGGER_GROUP + ); + +ALTER TABLE QRTZ_CRON_TRIGGERS ADD + CONSTRAINT FK_QRTZ_CRON_TRIGGERS_QRTZ_TRIGGERS FOREIGN KEY + ( + SCHED_NAME, + TRIGGER_NAME, + TRIGGER_GROUP + ) REFERENCES QRTZ_TRIGGERS ( + SCHED_NAME, + TRIGGER_NAME, + TRIGGER_GROUP + ) ON DELETE CASCADE; + + +ALTER TABLE QRTZ_SIMPLE_TRIGGERS ADD + CONSTRAINT FK_QRTZ_SIMPLE_TRIGGERS_QRTZ_TRIGGERS FOREIGN KEY + ( + SCHED_NAME, + TRIGGER_NAME, + TRIGGER_GROUP + ) REFERENCES QRTZ_TRIGGERS ( + SCHED_NAME, + TRIGGER_NAME, + TRIGGER_GROUP + ) ON DELETE CASCADE; + +ALTER TABLE QRTZ_SIMPROP_TRIGGERS ADD + CONSTRAINT FK_QRTZ_SIMPROP_TRIGGERS_QRTZ_TRIGGERS FOREIGN KEY + ( + SCHED_NAME, + TRIGGER_NAME, + TRIGGER_GROUP + ) REFERENCES QRTZ_TRIGGERS ( + SCHED_NAME, + TRIGGER_NAME, + TRIGGER_GROUP + ) ON DELETE CASCADE; + + +ALTER TABLE QRTZ_TRIGGERS ADD + CONSTRAINT FK_QRTZ_TRIGGERS_QRTZ_JOB_DETAILS FOREIGN KEY + ( + SCHED_NAME, + JOB_NAME, + JOB_GROUP + ) REFERENCES QRTZ_JOB_DETAILS ( + SCHED_NAME, + JOB_NAME, + JOB_GROUP + ); + +COMMIT; \ No newline at end of file From 0b4775a894911300586339702ae1b405d7216573 Mon Sep 17 00:00:00 2001 From: Marc Auberer Date: Tue, 18 May 2021 15:58:33 +0200 Subject: [PATCH 13/25] Deploy code coverage to SonarQube (#94) * Make Sonarqube also deploy code coverage * Switch to setup-java@v2 * Fix 'Java not found' error * Fix SonarQube coverage deployment --- .github/workflows/ci-with-docker-develop.yml | 16 ++- .github/workflows/ci-with-docker.yml | 16 ++- .github/workflows/ci.yml | 105 ++++++++++--------- .github/workflows/docs-build.yml | 15 ++- .github/workflows/sonarqube-checks.yml | 20 ---- 5 files changed, 91 insertions(+), 81 deletions(-) delete mode 100644 .github/workflows/sonarqube-checks.yml diff --git a/.github/workflows/ci-with-docker-develop.yml b/.github/workflows/ci-with-docker-develop.yml index 678ea094..4fc08820 100644 --- a/.github/workflows/ci-with-docker-develop.yml +++ b/.github/workflows/ci-with-docker-develop.yml @@ -11,11 +11,15 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up JDK 1.11 - uses: actions/setup-java@v1 + - name: Checkout + uses: actions/checkout@v2 + + - name: Set up JDK 11 + uses: actions/setup-java@v2 with: - java-version: 1.11 + distribution: zulu + java-version: 11 + - name: Build with Maven run: mvn -B package --file pom.xml env: @@ -34,8 +38,10 @@ jobs: LIVE_POLL_HTTPS_ENABLED: ${{ secrets.API_LIVE_POLL_HTTPS_ENABLED }} LIVE_POLL_HTTPS_CERT_PASSWORD: ${{ secrets.API_LIVE_POLL_HTTPS_CERT_PASSWORD }} CUCUMBER_PUBLISH_TOKEN: ${{ secrets.CUCUMBER_PUBLISH_TOKEN }} + - name: CodeCov report deploy run: bash <(curl -s https://codecov.io/bash) + - name: Prepare environment run: | sudo apt-get install --yes --no-install-recommends libxml-xpath-perl @@ -44,11 +50,13 @@ jobs: export VERSION=$(xpath -q -e "/project/version/text()" pom.xml) export VERSION=${VERSION//-SNAPSHOT}-$(git rev-parse --short ${GITHUB_SHA}) echo "VERSION=${VERSION}" >> $GITHUB_ENV + - name: GH Packages deploy uses: actions/upload-artifact@v1 with: name: target path: target + - name: Docker build and push run: | echo ${CR_PAT} | docker login ghcr.io -u ${GITHUB_REPOSITORY_OWNER} --password-stdin diff --git a/.github/workflows/ci-with-docker.yml b/.github/workflows/ci-with-docker.yml index 1b66f3f1..2e4795e3 100644 --- a/.github/workflows/ci-with-docker.yml +++ b/.github/workflows/ci-with-docker.yml @@ -13,11 +13,15 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up JDK 1.11 - uses: actions/setup-java@v1 + - name: Checkout + uses: actions/checkout@v2 + + - name: Set up JDK 11 + uses: actions/setup-java@v2 with: - java-version: 1.11 + distribution: zulu + java-version: 11 + - name: Build with Maven run: mvn -B package --file pom.xml env: @@ -36,8 +40,10 @@ jobs: LIVE_POLL_HTTPS_ENABLED: ${{ secrets.API_LIVE_POLL_HTTPS_ENABLED }} LIVE_POLL_HTTPS_CERT_PASSWORD: ${{ secrets.API_LIVE_POLL_HTTPS_CERT_PASSWORD }} CUCUMBER_PUBLISH_TOKEN: ${{ secrets.CUCUMBER_PUBLISH_TOKEN }} + - name: CodeCov report deploy run: bash <(curl -s https://codecov.io/bash) + - name: Prepare environment run: | sudo apt-get install --yes --no-install-recommends libxml-xpath-perl @@ -46,11 +52,13 @@ jobs: export VERSION=$(xpath -q -e "/project/version/text()" pom.xml) export VERSION=${VERSION//-SNAPSHOT}-$(git rev-parse --short ${GITHUB_SHA}) echo "VERSION=${VERSION}" >> $GITHUB_ENV + - name: GH Packages deploy uses: actions/upload-artifact@v1 with: name: target path: target + - name: Docker build and push run: | echo ${CR_PAT} | docker login ghcr.io -u ${GITHUB_REPOSITORY_OWNER} --password-stdin diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e6eee4b..200aeaa5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,51 +13,60 @@ jobs: build: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Set up JDK 1.11 - uses: actions/setup-java@v1 - with: - java-version: 1.11 - - - name: Build and test project with Maven - run: mvn -B package --file pom.xml --batch-mode --update-snapshots clean verify - env: - LIVE_POLL_MYSQL_URL: ${{ secrets.API_LIVE_POLL_MYSQL_URL }} - LIVE_POLL_MYSQL_USER: ${{ secrets.API_LIVE_POLL_MYSQL_USER }} - LIVE_POLL_MYSQL_PASSWORD: ${{ secrets.API_LIVE_POLL_MYSQL_PASSWORD }} - LIVE_POLL_DEV_URL: ${{ secrets.API_LIVE_POLL_DEV_URL }} - LIVE_POLL_SERVER_URL: ${{ secrets.API_LIVE_POLL_SERVER_URL }} - LIVE_POLL_MAIL_HOST: ${{ secrets.API_LIVE_POLL_MAIL_HOST }} - LIVE_POLL_MAIL_PORT: ${{ secrets.API_LIVE_POLL_MAIL_PORT }} - LIVE_POLL_MAIL_USERNAME: ${{ secrets.API_LIVE_POLL_MAIL_USERNAME }} - LIVE_POLL_MAIL_PASSWORD: ${{ secrets.API_LIVE_POLL_MAIL_PASSWORD }} - LIVE_POLL_JWT_AUTH_COOKIE_NAME: ${{ secrets.API_LIVE_POLL_JWT_AUTH_COOKIE_NAME }} - LIVE_POLL_JWT_COOKIE_KEY_VALUE: ${{ secrets.API_LIVE_POLL_JWT_COOKIE_KEY_VALUE }} - LIVE_POLL_JWT_SECRET: ${{ secrets.API_LIVE_POLL_JWT_SECRET }} - LIVE_POLL_HTTPS_ENABLED: ${{ secrets.API_LIVE_POLL_HTTPS_ENABLED }} - LIVE_POLL_HTTPS_CERT_PASSWORD: ${{ secrets.API_LIVE_POLL_HTTPS_CERT_PASSWORD }} - CUCUMBER_PUBLISH_TOKEN: ${{ secrets.CUCUMBER_PUBLISH_TOKEN }} - - - name: Setup testing environment - working-directory: ./.github/scripts - run: docker-compose up -d - env: - LIVE_POLL_MAIL_HOST: ${{ secrets.API_LIVE_POLL_MAIL_HOST }} - LIVE_POLL_MAIL_PORT: ${{ secrets.API_LIVE_POLL_MAIL_PORT }} - LIVE_POLL_MAIL_USERNAME: ${{ secrets.API_LIVE_POLL_MAIL_USERNAME }} - LIVE_POLL_MAIL_PASSWORD: ${{ secrets.API_LIVE_POLL_MAIL_PASSWORD }} - LIVE_POLL_HTTPS_CERT_PASSWORD: ${{ secrets.API_LIVE_POLL_HTTPS_CERT_PASSWORD }} - - - name: Check Docker - run: docker ps - - - name: Install Newman - run: sudo npm i -g newman - - - name: API healthcheck - run: curl -sSLf --retry-delay 5 --retry 5 --retry-connrefused --insecure http://example.org > /dev/null - - - name: Run Newman tests - run: newman run ./postman/Livepoll.postman_collection.json --iteration-count 3 --folder "Integration Test" --insecure \ No newline at end of file + - name: Checkout + uses: actions/checkout@v2 + + - name: Set up JDK 11 + uses: actions/setup-java@v2 + with: + distribution: zulu + java-version: 11 + + - name: Build and test project with Maven + run: mvn -B package --file pom.xml --batch-mode --update-snapshots + env: + LIVE_POLL_MYSQL_URL: ${{ secrets.API_LIVE_POLL_MYSQL_URL }} + LIVE_POLL_MYSQL_USER: ${{ secrets.API_LIVE_POLL_MYSQL_USER }} + LIVE_POLL_MYSQL_PASSWORD: ${{ secrets.API_LIVE_POLL_MYSQL_PASSWORD }} + LIVE_POLL_DEV_URL: ${{ secrets.API_LIVE_POLL_DEV_URL }} + LIVE_POLL_SERVER_URL: ${{ secrets.API_LIVE_POLL_SERVER_URL }} + LIVE_POLL_MAIL_HOST: ${{ secrets.API_LIVE_POLL_MAIL_HOST }} + LIVE_POLL_MAIL_PORT: ${{ secrets.API_LIVE_POLL_MAIL_PORT }} + LIVE_POLL_MAIL_USERNAME: ${{ secrets.API_LIVE_POLL_MAIL_USERNAME }} + LIVE_POLL_MAIL_PASSWORD: ${{ secrets.API_LIVE_POLL_MAIL_PASSWORD }} + LIVE_POLL_JWT_AUTH_COOKIE_NAME: ${{ secrets.API_LIVE_POLL_JWT_AUTH_COOKIE_NAME }} + LIVE_POLL_JWT_COOKIE_KEY_VALUE: ${{ secrets.API_LIVE_POLL_JWT_COOKIE_KEY_VALUE }} + LIVE_POLL_JWT_SECRET: ${{ secrets.API_LIVE_POLL_JWT_SECRET }} + LIVE_POLL_HTTPS_ENABLED: ${{ secrets.API_LIVE_POLL_HTTPS_ENABLED }} + LIVE_POLL_HTTPS_CERT_PASSWORD: ${{ secrets.API_LIVE_POLL_HTTPS_CERT_PASSWORD }} + CUCUMBER_PUBLISH_TOKEN: ${{ secrets.CUCUMBER_PUBLISH_TOKEN }} + + - name: SonarQube report deployment + uses: kitabisa/sonarqube-action@master + env: + JAVA_HOME: '' # Avoid 'java: not found' error + with: + host: ${{ secrets.SONARQUBE_HOST }} + login: ${{ secrets.SONARQUBE_TOKEN }} + + - name: Setup testing environment + working-directory: ./.github/scripts + run: docker-compose up -d + env: + LIVE_POLL_MAIL_HOST: ${{ secrets.API_LIVE_POLL_MAIL_HOST }} + LIVE_POLL_MAIL_PORT: ${{ secrets.API_LIVE_POLL_MAIL_PORT }} + LIVE_POLL_MAIL_USERNAME: ${{ secrets.API_LIVE_POLL_MAIL_USERNAME }} + LIVE_POLL_MAIL_PASSWORD: ${{ secrets.API_LIVE_POLL_MAIL_PASSWORD }} + LIVE_POLL_HTTPS_CERT_PASSWORD: ${{ secrets.API_LIVE_POLL_HTTPS_CERT_PASSWORD }} + + - name: Check Docker + run: docker ps + + - name: Install Newman + run: sudo npm i -g newman + + - name: API healthcheck + run: curl -sSLf --retry-delay 5 --retry 5 --retry-connrefused --insecure http://example.org > /dev/null + + - name: Run Newman tests + run: newman run ./postman/Livepoll.postman_collection.json --iteration-count 3 --folder "Integration Test" --insecure \ No newline at end of file diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index 4e86e06d..d783f62d 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -5,21 +5,26 @@ name: Docs Build & Deploy on: push: - branches: [ main ] + branches: [ main, develop ] jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up JDK 1.11 - uses: actions/setup-java@v1 + - name: Checkout + uses: actions/checkout@v2 + + - name: Set up JDK 11 + uses: actions/setup-java@v2 with: - java-version: 1.11 + distribution: zulu + java-version: 11 + - name: Build Documentation run: | mvn clean dokka:dokka git reset --hard HEAD + - name: Deploy docs uses: SamKirkland/FTP-Deploy-Action@3.1.1 with: diff --git a/.github/workflows/sonarqube-checks.yml b/.github/workflows/sonarqube-checks.yml deleted file mode 100644 index 2ffed5e7..00000000 --- a/.github/workflows/sonarqube-checks.yml +++ /dev/null @@ -1,20 +0,0 @@ -# Workflow for SonarQube - -name: SonarQube Checks - -on: - push: - pull_request: - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: SonarQube Scan - uses: kitabisa/sonarqube-action@master - with: - host: ${{ secrets.SONARQUBE_HOST }} - login: ${{ secrets.SONARQUBE_TOKEN }} From a3307167d31caf29040d8e6ad6a98cc4cd098f83 Mon Sep 17 00:00:00 2001 From: Splines <37160523+Splines@users.noreply.github.com> Date: Wed, 19 May 2021 00:05:04 +0200 Subject: [PATCH 14/25] Implement logic for coherently updating poll item positions (#93) * Implement move poll item position * Cleanup line * Update postman integration tests: move position * Add presumption to move poll item algorithm * Ensure poll item position is not taken on creation * Add false "position on creation" test to Postman * Fix failing Newman tests (send cookies explicitly) * Remove "position" from DTOs for creating poll item * Remove "position" from Postman poll item creation Also added tests to ensure that new poll items are always appended at the end (increase position by one). * Fake dev environment for Newman tests * Fix usage of globals instead of environment * Replace when statement with if statement * Use inheritance for poll item DtoIn. * Remove unused function Co-authored-by: Niklas-23 <65356992+Niklas-23@users.noreply.github.com> Co-authored-by: Marc Auberer --- postman/Livepoll.postman_collection.json | 153 +++++++++++++++--- .../api/controller/PollItemController.kt | 11 +- .../api/entity/dto/MultipleChoiceItemDtoIn.kt | 24 ++- .../api/entity/dto/OpenTextItemDtoIn.kt | 15 +- .../livepoll/api/entity/dto/PollItemDtoIn.kt | 6 + .../livepoll/api/entity/dto/QuizItemDtoIn.kt | 16 +- .../livepoll/api/service/PollItemService.kt | 89 +++++++--- .../de/livepoll/api/util/CustomModelMapper.kt | 1 + 8 files changed, 246 insertions(+), 69 deletions(-) create mode 100644 src/main/kotlin/de/livepoll/api/entity/dto/PollItemDtoIn.kt diff --git a/postman/Livepoll.postman_collection.json b/postman/Livepoll.postman_collection.json index c363a74b..85e0627d 100644 --- a/postman/Livepoll.postman_collection.json +++ b/postman/Livepoll.postman_collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "65ba7f23-1487-472a-a87c-4af4209e407e", + "_postman_id": "e270b048-0307-4376-9110-ccde318ad785", "name": "Livepoll", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, @@ -416,7 +416,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-multiplechoice-question\",\r\n \"position\": 1,\r\n \"answers\": [\"postman-multiplechoice-answer-1\", \"postman-multiplechoice-answer-2\"]\r\n}", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-multiplechoice-question\",\r\n \"answers\": [\"postman-multiplechoice-answer-1\", \"postman-multiplechoice-answer-2\"]\r\n}", "options": { "raw": { "language": "json" @@ -457,7 +457,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-multiplechoice-question-update\",\r\n \"position\": 1,\r\n \"answers\": [\"postman-multiplechoice-answer-1-update\", \"postman-multiplechoice-answer-2-update\"]\r\n}", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-multiplechoice-question-update\",\r\n \"position\": 3,\r\n \"answers\": [\"postman-multiplechoice-answer-1-update\", \"postman-multiplechoice-answer-2-update\"]\r\n}", "options": { "raw": { "language": "json" @@ -499,7 +499,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"position\": 1,\r\n \"question\": \"postman-quiz-question\",\r\n \"answers\": [ \"correct option\", \"wrong option\", \"wrong option\"]\r\n}", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-quiz-question\",\r\n \"answers\": [ \"correct option\", \"wrong option\", \"wrong option\"]\r\n}", "options": { "raw": { "language": "json" @@ -582,7 +582,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"position\": 1,\r\n \"question\": \"postman-open-text-question\"\r\n}", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-open-text-question\"\r\n}", "options": { "raw": { "language": "json" @@ -623,7 +623,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"position\": 1,\r\n \"question\": \"postman-open-text-question update\"\r\n}", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"position\": 2,\r\n \"question\": \"postman-open-text-question update\"\r\n}", "options": { "raw": { "language": "json" @@ -706,10 +706,14 @@ "listen": "prerequest", "script": { "exec": [ - "pm.globals.unset(\"poll-id\")\r", - "pm.globals.unset(\"poll-item-id\")\r", - "pm.globals.unset(\"poll-items\")\r", - "pm.globals.unset(\"poll-items-counter\")" + "pm.globals.unset(\"poll-id\");\r", + "pm.globals.unset(\"poll-item-id\");\r", + "pm.globals.unset(\"poll-items\");\r", + "pm.globals.unset(\"poll-items-counter\");\r", + "\r", + "// Just for newman since we don't have our Livepoll Dev environment there\r", + "pm.environment.set('base-url', 'https://localhost:8080');\r", + "" ], "type": "text/javascript" } @@ -919,8 +923,15 @@ "pm.test(\"Status code is 201\", function () {\r", " pm.response.to.have.status(201);\r", "});\r", - "pm.globals.set(\"poll-item-id-multiple-choice\", pm.response.json().itemId)\r", - "pm.globals.set(\"poll-item-id\", pm.response.json().itemId)" + "\r", + "pm.test(\"Multiple choice item is at position 1\", () => {\r", + " const response = pm.response.json();\r", + " pm.expect(response.position).to.equal(1);\r", + "});\r", + "\r", + "pm.globals.set(\"poll-item-id-multiple-choice\", pm.response.json().itemId);\r", + "pm.globals.set(\"poll-item-id\", pm.response.json().itemId);\r", + "" ], "type": "text/javascript" } @@ -937,7 +948,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-multiplechoice-question\",\r\n \"position\": 1,\r\n \"answers\": [\"postman-multiplechoice-answer-1\", \"postman-multiplechoice-answer-2\"]\r\n}", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-multiplechoice-question\",\r\n \"answers\": [\"postman-multiplechoice-answer-1\", \"postman-multiplechoice-answer-2\"]\r\n}", "options": { "raw": { "language": "json" @@ -968,7 +979,14 @@ "pm.test(\"Status code is 201\", function () {\r", " pm.response.to.have.status(201);\r", "});\r", - "pm.globals.set(\"poll-item-id-quiz\", pm.response.json().itemId)" + "\r", + "pm.test(\"Quiz item is at position 2\", () => {\r", + " const response = pm.response.json();\r", + " pm.expect(response.position).to.equal(2);\r", + "});\r", + "\r", + "pm.globals.set(\"poll-item-id-quiz\", pm.response.json().itemId);\r", + "" ], "type": "text/javascript" } @@ -985,7 +1003,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"position\": 1,\r\n \"question\": \"postman-quiz-question\",\r\n \"answers\": [ \"correct option\", \"wrong option\", \"wrong option\"]\r\n}", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-quiz-question\",\r\n \"answers\": [ \"correct option\", \"wrong option\", \"wrong option\"]\r\n}", "options": { "raw": { "language": "json" @@ -1016,7 +1034,13 @@ "pm.test(\"Status code is 201\", function () {\r", " pm.response.to.have.status(201);\r", "});\r", - "pm.globals.set(\"poll-item-id-open-text\", pm.response.json().itemId)\r", + "\r", + "pm.test(\"Open text item is at position 3\", () => {\r", + " const response = pm.response.json();\r", + " pm.expect(response.position).to.equal(3);\r", + "});\r", + "\r", + "pm.globals.set(\"poll-item-id-open-text\", pm.response.json().itemId);\r", "" ], "type": "text/javascript" @@ -1034,7 +1058,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"position\": 1,\r\n \"question\": \"postman-open-text-question\"\r\n}", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-open-text-question\"\r\n}", "options": { "raw": { "language": "json" @@ -1218,7 +1242,36 @@ "exec": [ "pm.test(\"Status code is 200\", function () {\r", " pm.response.to.have.status(200);\r", - "});" + "});\r", + "\r", + "pm.test(\"Multiple choice item got moved from position 1 to position 3\", () => {\r", + " const baseUrl = pm.environment.get('base-url');\r", + " const pollId = pm.globals.get('poll-id');\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/v1/polls/${pollId}/poll-items`,\r", + " method: 'GET',\r", + " header: `Cookie:${pm.globals.get('cookies')}`\r", + " }, function (err, response) {\r", + " response = response.json();\r", + "\r", + " // Poll item Ids\r", + " const multipleChoiceId = pm.globals.get('poll-item-id-multiple-choice');\r", + " const quizId = pm.globals.get('poll-item-id-quiz');\r", + " const openTextId = pm.globals.get('poll-item-id-open-text');\r", + "\r", + " // Multiple choice item got moved from position 1 to position 3\r", + " pm.expect(response[0].itemId).to.equal(multipleChoiceId);\r", + " pm.expect(response[0].position).to.equal(3);\r", + "\r", + " pm.expect(response[1].itemId).to.equal(quizId);\r", + " pm.expect(response[1].position).to.equal(1);\r", + "\r", + " pm.expect(response[2].itemId).to.equal(openTextId);\r", + " pm.expect(response[2].position).to.equal(2);\r", + " });\r", + "});\r", + "" ], "type": "text/javascript" } @@ -1235,7 +1288,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-multiplechoice-question-update\",\r\n \"position\": 1,\r\n \"answers\": [\"postman-multiplechoice-answer-1-update\", \"postman-multiplechoice-answer-2-update\"]\r\n}", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-multiplechoice-question-update\",\r\n \"position\": 3,\r\n \"answers\": [\"postman-multiplechoice-answer-1-update\", \"postman-multiplechoice-answer-2-update\"]\r\n}", "options": { "raw": { "language": "json" @@ -1266,7 +1319,36 @@ "exec": [ "pm.test(\"Status code is 200\", function () {\r", " pm.response.to.have.status(200);\r", - "});" + "});\r", + "\r", + "pm.test(\"Quiz item stays at position 1\", () => {\r", + " const baseUrl = pm.environment.get('base-url');\r", + " const pollId = pm.globals.get('poll-id');\r", + "\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/v1/polls/${pollId}/poll-items`,\r", + " method: 'GET',\r", + " header: `Cookie:${pm.globals.get('cookies')}`\r", + " }, function (err, response) {\r", + " response = response.json();\r", + "\r", + " // Poll item Ids\r", + " const multipleChoiceId = pm.globals.get('poll-item-id-multiple-choice');\r", + " const quizId = pm.globals.get('poll-item-id-quiz');\r", + " const openTextId = pm.globals.get('poll-item-id-open-text');\r", + "\r", + " // Quiz item stays at position 1\r", + " pm.expect(response[0].itemId).to.equal(multipleChoiceId);\r", + " pm.expect(response[0].position).to.equal(3);\r", + "\r", + " pm.expect(response[1].itemId).to.equal(quizId);\r", + " pm.expect(response[1].position).to.equal(1);\r", + "\r", + " pm.expect(response[2].itemId).to.equal(openTextId);\r", + " pm.expect(response[2].position).to.equal(2);\r", + " });\r", + "});\r", + "" ], "type": "text/javascript" } @@ -1314,7 +1396,36 @@ "exec": [ "pm.test(\"Status code is 200\", function () {\r", " pm.response.to.have.status(200);\r", - "});" + "});\r", + "\r", + "pm.test(\"Open text item got moved from position 2 to position 1\", () => {\r", + " const baseUrl = pm.environment.get('base-url');\r", + " const pollId = pm.globals.get('poll-id');\r", + " \r", + " pm.sendRequest({\r", + " url: `${baseUrl}/v1/polls/${pollId}/poll-items`,\r", + " method: 'GET',\r", + " header: `Cookie:${pm.globals.get('cookies')}`\r", + " }, function (err, response) {\r", + " response = response.json();\r", + "\r", + " // Poll item Ids\r", + " const multipleChoiceId = pm.globals.get('poll-item-id-multiple-choice');\r", + " const quizId = pm.globals.get('poll-item-id-quiz');\r", + " const openTextId = pm.globals.get('poll-item-id-open-text');\r", + "\r", + " // Open text item got moved from position 2 to position 1\r", + " pm.expect(response[0].itemId).to.equal(multipleChoiceId);\r", + " pm.expect(response[0].position).to.equal(3);\r", + "\r", + " pm.expect(response[1].itemId).to.equal(quizId);\r", + " pm.expect(response[1].position).to.equal(2);\r", + "\r", + " pm.expect(response[2].itemId).to.equal(openTextId);\r", + " pm.expect(response[2].position).to.equal(1);\r", + " });\r", + "});\r", + "" ], "type": "text/javascript" } diff --git a/src/main/kotlin/de/livepoll/api/controller/PollItemController.kt b/src/main/kotlin/de/livepoll/api/controller/PollItemController.kt index 7ef84e0c..422fdc03 100644 --- a/src/main/kotlin/de/livepoll/api/controller/PollItemController.kt +++ b/src/main/kotlin/de/livepoll/api/controller/PollItemController.kt @@ -1,9 +1,6 @@ package de.livepoll.api.controller -import de.livepoll.api.entity.dto.MultipleChoiceItemDtoIn -import de.livepoll.api.entity.dto.OpenTextItemDtoIn -import de.livepoll.api.entity.dto.PollItemDtoOut -import de.livepoll.api.entity.dto.QuizItemDtoIn +import de.livepoll.api.entity.dto.* import de.livepoll.api.service.AccountService import de.livepoll.api.service.PollItemService import de.livepoll.api.service.PollService @@ -58,21 +55,21 @@ class PollItemController( @ApiOperation(value = "Update multiple choice item", tags = ["Poll item"]) @PutMapping("/multiple-choice/{pollItemId}") - fun updateMultipleChoiceItem(@RequestBody updatedItem: MultipleChoiceItemDtoIn, @PathVariable(name="pollItemId") pollItemId: Long): ResponseEntity<*> { + fun updateMultipleChoiceItem(@RequestBody updatedItem: MultipleChoiceItemWithPositionDtoIn, @PathVariable(name="pollItemId") pollItemId: Long): ResponseEntity<*> { accountService.checkAuthorizationByPollItemId(pollItemId) return ResponseEntity.ok(pollItemService.updateMultipleChoiceItem(pollItemId, updatedItem)) } @ApiOperation(value = "Update quiz item", tags = ["Poll item"]) @PutMapping("/quiz/{pollItemId}") - fun updateQuizItem(@RequestBody updatedItem: QuizItemDtoIn, @PathVariable(name="pollItemId") pollItemId: Long): ResponseEntity<*> { + fun updateQuizItem(@RequestBody updatedItem: QuizItemWithPositionDtoIn, @PathVariable(name="pollItemId") pollItemId: Long): ResponseEntity<*> { accountService.checkAuthorizationByPollItemId(pollItemId) return ResponseEntity.ok(pollItemService.updateQuizItem(pollItemId, updatedItem)) } @ApiOperation(value = "Update open text item", tags = ["Poll item"]) @PutMapping("/open-text/{pollItemId}") - fun updateOpenTextItem(@RequestBody updatedItem: OpenTextItemDtoIn, @PathVariable(name="pollItemId") pollItemId: Long): ResponseEntity<*> { + fun updateOpenTextItem(@RequestBody updatedItem: OpenTextItemWithPositionDtoIn, @PathVariable(name="pollItemId") pollItemId: Long): ResponseEntity<*> { accountService.checkAuthorizationByPollItemId(pollItemId) return ResponseEntity.ok(pollItemService.updateOpenTextItem(pollItemId, updatedItem)) } diff --git a/src/main/kotlin/de/livepoll/api/entity/dto/MultipleChoiceItemDtoIn.kt b/src/main/kotlin/de/livepoll/api/entity/dto/MultipleChoiceItemDtoIn.kt index acecc047..08b22f67 100644 --- a/src/main/kotlin/de/livepoll/api/entity/dto/MultipleChoiceItemDtoIn.kt +++ b/src/main/kotlin/de/livepoll/api/entity/dto/MultipleChoiceItemDtoIn.kt @@ -1,10 +1,18 @@ package de.livepoll.api.entity.dto -data class MultipleChoiceItemDtoIn( - val pollId: Long, - val question: String, - val position: Int, - val allowMultipleAnswers: Boolean, - val allowBlankField: Boolean, - val answers: List, -) \ No newline at end of file +open class MultipleChoiceItemDtoIn( + pollId: Long, + question: String, + val allowMultipleAnswers: Boolean, + val allowBlankField: Boolean, + val answers: List, +) : PollItemDtoIn(pollId, question) + +class MultipleChoiceItemWithPositionDtoIn( + pollId: Long, + question: String, + allowMultipleAnswers: Boolean, + allowBlankField: Boolean, + answers: List, + val position: Int +) : MultipleChoiceItemDtoIn(pollId, question, allowMultipleAnswers, allowBlankField, answers) diff --git a/src/main/kotlin/de/livepoll/api/entity/dto/OpenTextItemDtoIn.kt b/src/main/kotlin/de/livepoll/api/entity/dto/OpenTextItemDtoIn.kt index 5b9d956a..e1777d76 100644 --- a/src/main/kotlin/de/livepoll/api/entity/dto/OpenTextItemDtoIn.kt +++ b/src/main/kotlin/de/livepoll/api/entity/dto/OpenTextItemDtoIn.kt @@ -1,7 +1,12 @@ package de.livepoll.api.entity.dto -data class OpenTextItemDtoIn( - val pollId: Long, - val position: Int, - val question: String, -) +open class OpenTextItemDtoIn( + pollId: Long, + question: String, +) : PollItemDtoIn(pollId, question) + +class OpenTextItemWithPositionDtoIn( + pollId: Long, + question: String, + val position: Int +) : OpenTextItemDtoIn(pollId, question) diff --git a/src/main/kotlin/de/livepoll/api/entity/dto/PollItemDtoIn.kt b/src/main/kotlin/de/livepoll/api/entity/dto/PollItemDtoIn.kt new file mode 100644 index 00000000..3422ae36 --- /dev/null +++ b/src/main/kotlin/de/livepoll/api/entity/dto/PollItemDtoIn.kt @@ -0,0 +1,6 @@ +package de.livepoll.api.entity.dto + +open class PollItemDtoIn( + val pollId: Long, + val question: String +) diff --git a/src/main/kotlin/de/livepoll/api/entity/dto/QuizItemDtoIn.kt b/src/main/kotlin/de/livepoll/api/entity/dto/QuizItemDtoIn.kt index 79f8f241..648bc1d8 100644 --- a/src/main/kotlin/de/livepoll/api/entity/dto/QuizItemDtoIn.kt +++ b/src/main/kotlin/de/livepoll/api/entity/dto/QuizItemDtoIn.kt @@ -1,8 +1,14 @@ package de.livepoll.api.entity.dto -class QuizItemDtoIn( - var pollId: Long, - val position: Int, - val question: String, +open class QuizItemDtoIn( + pollId: Long, + question: String, val answers: List -) +) : PollItemDtoIn(pollId, question) + +class QuizItemWithPositionDtoIn( + pollId: Long, + question: String, + answers: List, + val position: Int +) : QuizItemDtoIn(pollId, question, answers) diff --git a/src/main/kotlin/de/livepoll/api/service/PollItemService.kt b/src/main/kotlin/de/livepoll/api/service/PollItemService.kt index 0244a763..0430cf53 100644 --- a/src/main/kotlin/de/livepoll/api/service/PollItemService.kt +++ b/src/main/kotlin/de/livepoll/api/service/PollItemService.kt @@ -8,8 +8,6 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import org.springframework.web.server.ResponseStatusException -import javax.sql.rowset.Predicate -import javax.swing.text.MutableAttributeSet @Service class PollItemService { @@ -79,7 +77,8 @@ class PollItemService { val multipleChoiceItem = MultipleChoiceItem( 0, this, - item.position, + // Insert new item at the end of the poll items (position is counted from 1 onwards) + this.pollItems.size + 1, item.question, item.allowMultipleAnswers, item.allowBlankField, @@ -106,7 +105,8 @@ class PollItemService { val quizItem = QuizItem( 0, this, - item.position, + // Insert new item at the end of the poll items (position is counted from 1 onwards) + this.pollItems.size + 1, item.question, mutableListOf() ) @@ -133,7 +133,8 @@ class PollItemService { 0, this, item.question, - item.position, + // Insert new item at the end of the poll items (position is counted from 1 onwards) + this.pollItems.size + 1, emptyList().toMutableList() ) return openTextItemRepository.saveAndFlush(openTextItem).toDtoOut() @@ -143,7 +144,45 @@ class PollItemService { //-------------------------------------------- Update -------------------------------------------------------------- - fun updateMultipleChoiceItem(pollItemId: Long, pollItem: MultipleChoiceItemDtoIn): MultipleChoiceItemDtoOut { + /** + * Move poll item to another position while updating the positions of other poll items in the poll.
+ * + * This method works in-place and will adjust the poll items list.
+ * Old and new position are counted from 1 onwards, NOT from 0! + */ + fun movePollItem(oldPos: Int, newPos: Int, pollItems: MutableList) { + // Check if new position is existent + if (!(newPos >= 1 && newPos <= pollItems.size)) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid poll item position") + } + + // Sort poll items by position instead of id + pollItems.sortBy { it.position } + + // Update elements in between + if (oldPos < newPos) { + for (i in oldPos + 1..newPos) { // For every element in [oldPos+1, newPos] + pollItems[i - 1].position-- // Decrease position by one + pollItemRepository.saveAndFlush(pollItems[i - 1]) + } + } else if (oldPos > newPos) { + for (i in newPos until oldPos) { // For every element in [newPos, oldPos-1] + pollItems[i - 1].position++ // Increase position by one + pollItemRepository.saveAndFlush(pollItems[i - 1]) + } + } else { // oldPos == newPos + // do nothing + } + + // Update position of explicitly requested poll item + pollItems[oldPos - 1].position = newPos + pollItemRepository.saveAndFlush(pollItems[oldPos - 1]) + } + + fun updateMultipleChoiceItem( + pollItemId: Long, + pollItem: MultipleChoiceItemWithPositionDtoIn + ): MultipleChoiceItemDtoOut { multipleChoiceItemRepository.findById(pollItemId) .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Poll item not found") } .run { @@ -151,13 +190,13 @@ class PollItemService { val removeAnswer = mutableListOf() this.answers.forEach { if (it.answerCount != 0) { - if(pollItem.answers.contains(it.selectionOption)){ + if (pollItem.answers.contains(it.selectionOption)) { newAnswers.remove(it.selectionOption) - }else{ + } else { this.answers.remove(it) } - }else{ - if(!pollItem.answers.contains(it.selectionOption)){ + } else { + if (!pollItem.answers.contains(it.selectionOption)) { removeAnswer.add(it) } } @@ -172,13 +211,14 @@ class PollItemService { this.question = pollItem.question this.allowMultipleAnswers = pollItem.allowMultipleAnswers this.allowBlankField = pollItem.allowBlankField - this.position = pollItem.position + movePollItem(this.position, pollItem.position, this.poll.pollItems) + pollRepository.saveAndFlush(this.poll) return pollItemRepository.saveAndFlush(this).toDtoOut() } } - fun updateQuizItem(pollItemId: Long, pollItem: QuizItemDtoIn): QuizItemDtoOut { + fun updateQuizItem(pollItemId: Long, pollItem: QuizItemWithPositionDtoIn): QuizItemDtoOut { quizItemRepository.findById(pollItemId) .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Poll item not found") } .run { @@ -186,14 +226,14 @@ class PollItemService { val removeAnswer = mutableListOf() this.answers.forEach { if (it.answerCount != 0) { - if(pollItem.answers.contains(it.selectionOption)){ + if (pollItem.answers.contains(it.selectionOption)) { newAnswers.remove(it.selectionOption) it.isCorrect = false - }else{ + } else { this.answers.remove(it) } - }else{ - if(!pollItem.answers.contains(it.selectionOption)){ + } else { + if (!pollItem.answers.contains(it.selectionOption)) { removeAnswer.add(it) } } @@ -204,27 +244,30 @@ class PollItemService { newAnswers.forEach { this.answers.add(QuizItemAnswer(0, this, it, false, 0)) } - val newCorrectOne = this.answers.find{it.selectionOption == pollItem.answers[0]}!! - if(newCorrectOne.answerCount == 0){ - this.answers.find{it.selectionOption == pollItem.answers[0]}!!.isCorrect = true + val newCorrectOne = this.answers.find { it.selectionOption == pollItem.answers[0] }!! + if (newCorrectOne.answerCount == 0) { + this.answers.find { it.selectionOption == pollItem.answers[0] }!!.isCorrect = true } this.question = pollItem.question - this.position = pollItem.position + movePollItem(this.position, pollItem.position, this.poll.pollItems) + pollRepository.saveAndFlush(this.poll) return quizItemRepository.saveAndFlush(this).toDtoOut() } } - fun updateOpenTextItem(pollItemId: Long, pollItem: OpenTextItemDtoIn): OpenTextItemDtoOut { + fun updateOpenTextItem(pollItemId: Long, pollItem: OpenTextItemWithPositionDtoIn): OpenTextItemDtoOut { openTextItemRepository.findById(pollItemId) .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Poll item not found") } .run { - if (!this.answers.isEmpty()) { + if (this.answers.isNotEmpty()) { throw ResponseStatusException(HttpStatus.CONFLICT, "This item can not be updated anymore") } this.question = pollItem.question - this.position = pollItem.position + movePollItem(this.position, pollItem.position, this.poll.pollItems) + pollRepository.saveAndFlush(this.poll) + return openTextItemRepository.saveAndFlush(this).toDtoOut() } } diff --git a/src/main/kotlin/de/livepoll/api/util/CustomModelMapper.kt b/src/main/kotlin/de/livepoll/api/util/CustomModelMapper.kt index 6f7cd3de..38f77e18 100644 --- a/src/main/kotlin/de/livepoll/api/util/CustomModelMapper.kt +++ b/src/main/kotlin/de/livepoll/api/util/CustomModelMapper.kt @@ -9,6 +9,7 @@ fun Poll.toDtoOut(): PollDtoOut { return PollDtoOut(this.id, this.name, this.startDate, this.endDate, this.slug, this.currentItem) } + // --------------------------------------------------- User mappers ---------------------------------------------------- fun User.toDtoOut(): UserDtoOut { From d53caf669534288c816cbaa0952720205ec22dd0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 May 2021 15:28:57 +0200 Subject: [PATCH 15/25] Bump spring-boot-starter-quartz from 2.2.0.RELEASE to 2.4.5 (#96) Bumps [spring-boot-starter-quartz](https://github.com/spring-projects/spring-boot) from 2.2.0.RELEASE to 2.4.5. - [Release notes](https://github.com/spring-projects/spring-boot/releases) - [Commits](https://github.com/spring-projects/spring-boot/compare/v2.2.0.RELEASE...v2.4.5) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 409319fb..6ce2ea43 100644 --- a/pom.xml +++ b/pom.xml @@ -24,7 +24,7 @@ org.springframework.boot spring-boot-starter-quartz - 2.2.0.RELEASE + 2.4.5 From db37ac915692b6d9505845b5e0a15447d38804b9 Mon Sep 17 00:00:00 2001 From: Splines <37160523+Splines@users.noreply.github.com> Date: Thu, 20 May 2021 18:18:12 +0200 Subject: [PATCH 16/25] Show error messages locally and in GitHub tests (#98) --- .github/scripts/docker-compose.yml | 1 + src/main/resources/application.yml | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/.github/scripts/docker-compose.yml b/.github/scripts/docker-compose.yml index cbdf05ea..e9734dd1 100644 --- a/.github/scripts/docker-compose.yml +++ b/.github/scripts/docker-compose.yml @@ -37,6 +37,7 @@ services: LIVE_POLL_HTTPS_ENABLED: "true" LIVE_POLL_HTTPS_CERT_PASSWORD: ${LIVE_POLL_HTTPS_CERT_PASSWORD} LIVE_POLL_POSTMAN: "true" + server.error.include-message: "always" depends_on: db: condition: service_healthy diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 73a7c153..7a8a4d3b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -5,6 +5,12 @@ server: enabled: true mime-types: text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml min-response-size: 1024 + error: + # https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#common-application-properties-server + # https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-2.3-Release-Notes#changes-to-the-default-error-pages-content + include-message: on-param + include-binding-errors: always + include-stacktrace: NEVER spring: application.name: Live-Poll API From 4def78c4249238512b674eaa0a7e4d8a9d744dd4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 May 2021 13:02:00 +0200 Subject: [PATCH 17/25] Bump spring-boot-starter-parent from 2.4.5 to 2.5.0 (#99) Bumps [spring-boot-starter-parent](https://github.com/spring-projects/spring-boot) from 2.4.5 to 2.5.0. - [Release notes](https://github.com/spring-projects/spring-boot/releases) - [Commits](https://github.com/spring-projects/spring-boot/compare/v2.4.5...v2.5.0) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 6ce2ea43..bc231346 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 2.4.5 + 2.5.0 de.live-poll From 2152692a954bb08a5bc1272ef6948059b9de612b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 22 May 2021 04:50:50 +0200 Subject: [PATCH 18/25] Bump spring-boot-starter-quartz from 2.4.5 to 2.5.0 (#100) Bumps [spring-boot-starter-quartz](https://github.com/spring-projects/spring-boot) from 2.4.5 to 2.5.0. - [Release notes](https://github.com/spring-projects/spring-boot/releases) - [Commits](https://github.com/spring-projects/spring-boot/compare/v2.4.5...v2.5.0) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index bc231346..19d55d6c 100644 --- a/pom.xml +++ b/pom.xml @@ -24,7 +24,7 @@ org.springframework.boot spring-boot-starter-quartz - 2.4.5 + 2.5.0 From 9ea6616c32c1719a9e7178126d5d1f9164df41d6 Mon Sep 17 00:00:00 2001 From: Marc Auberer Date: Sat, 22 May 2021 16:07:13 +0200 Subject: [PATCH 19/25] Bump Cucumber from 6.10.3 to 6.10.4 (#101) * Bump Cucumber from 6.10.3 to 6.10.4 * Reduce verbosity of Maven package command * Optimize workflow runtime by reordering steps --- .github/workflows/ci-with-docker-develop.yml | 2 +- .github/workflows/ci-with-docker.yml | 2 +- .github/workflows/ci.yml | 19 ++++++++----------- pom.xml | 18 +++++++++--------- 4 files changed, 19 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci-with-docker-develop.yml b/.github/workflows/ci-with-docker-develop.yml index 4fc08820..8fb07fa4 100644 --- a/.github/workflows/ci-with-docker-develop.yml +++ b/.github/workflows/ci-with-docker-develop.yml @@ -21,7 +21,7 @@ jobs: java-version: 11 - name: Build with Maven - run: mvn -B package --file pom.xml + run: mvn package --file pom.xml --batch-mode --quiet --no-transfer-progress env: LIVE_POLL_MYSQL_URL: ${{ secrets.API_LIVE_POLL_MYSQL_URL }} LIVE_POLL_MYSQL_USER: ${{ secrets.API_LIVE_POLL_MYSQL_USER }} diff --git a/.github/workflows/ci-with-docker.yml b/.github/workflows/ci-with-docker.yml index 2e4795e3..aeeb2425 100644 --- a/.github/workflows/ci-with-docker.yml +++ b/.github/workflows/ci-with-docker.yml @@ -23,7 +23,7 @@ jobs: java-version: 11 - name: Build with Maven - run: mvn -B package --file pom.xml + run: mvn package --file pom.xml --batch-mode --quiet --no-transfer-progress env: LIVE_POLL_MYSQL_URL: ${{ secrets.API_LIVE_POLL_MYSQL_URL }} LIVE_POLL_MYSQL_USER: ${{ secrets.API_LIVE_POLL_MYSQL_USER }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 200aeaa5..57f98c49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: java-version: 11 - name: Build and test project with Maven - run: mvn -B package --file pom.xml --batch-mode --update-snapshots + run: mvn package --file pom.xml --batch-mode --update-snapshots --quiet --no-transfer-progress env: LIVE_POLL_MYSQL_URL: ${{ secrets.API_LIVE_POLL_MYSQL_URL }} LIVE_POLL_MYSQL_USER: ${{ secrets.API_LIVE_POLL_MYSQL_USER }} @@ -41,14 +41,6 @@ jobs: LIVE_POLL_HTTPS_CERT_PASSWORD: ${{ secrets.API_LIVE_POLL_HTTPS_CERT_PASSWORD }} CUCUMBER_PUBLISH_TOKEN: ${{ secrets.CUCUMBER_PUBLISH_TOKEN }} - - name: SonarQube report deployment - uses: kitabisa/sonarqube-action@master - env: - JAVA_HOME: '' # Avoid 'java: not found' error - with: - host: ${{ secrets.SONARQUBE_HOST }} - login: ${{ secrets.SONARQUBE_TOKEN }} - - name: Setup testing environment working-directory: ./.github/scripts run: docker-compose up -d @@ -59,8 +51,13 @@ jobs: LIVE_POLL_MAIL_PASSWORD: ${{ secrets.API_LIVE_POLL_MAIL_PASSWORD }} LIVE_POLL_HTTPS_CERT_PASSWORD: ${{ secrets.API_LIVE_POLL_HTTPS_CERT_PASSWORD }} - - name: Check Docker - run: docker ps + - name: SonarQube report deployment + uses: kitabisa/sonarqube-action@master + env: + JAVA_HOME: '' # Avoid 'java: not found' error + with: + host: ${{ secrets.SONARQUBE_HOST }} + login: ${{ secrets.SONARQUBE_TOKEN }} - name: Install Newman run: sudo npm i -g newman diff --git a/pom.xml b/pom.xml index 19d55d6c..a1de3cd3 100644 --- a/pom.xml +++ b/pom.xml @@ -105,14 +105,21 @@ io.cucumber cucumber-java - 6.10.3 + 6.10.4 test io.cucumber cucumber-junit - 6.10.3 + 6.10.4 + test + + + + io.cucumber + cucumber-spring + 6.10.4 test @@ -129,13 +136,6 @@ 5.5.3 - - io.cucumber - cucumber-spring - 6.10.3 - test - - io.springfox springfox-boot-starter From b0746b1600e16434b9745071a5a06af35f4cc6a7 Mon Sep 17 00:00:00 2001 From: Splines <37160523+Splines@users.noreply.github.com> Date: Sun, 23 May 2021 11:42:30 +0200 Subject: [PATCH 20/25] Fix/update poll item answer (#97) * Fix updating poll item answers logic * Fix update answers logic and use inheritance * Add postman tests to test selection options update * Set https to false for Newman * Fix updating correct item * Fix answer count check and move checks upwards * Improve update multiple choice postman tests * Rename "answers" to "selectionOptions" in Postman Co-authored-by: Marc Auberer --- .github/scripts/docker-compose.yml | 2 +- postman/Livepoll.postman_collection.json | 306 +++++++++++++++--- .../api/entity/db/MultipleChoiceItem.kt | 4 +- .../api/entity/db/PollItemAnswerable.kt | 11 + .../de/livepoll/api/entity/db/QuizItem.kt | 4 +- .../api/entity/db/SelectionOptionAnswer.kt | 5 +- .../api/entity/dto/MultipleChoiceItemDtoIn.kt | 6 +- .../livepoll/api/entity/dto/QuizItemDtoIn.kt | 6 +- .../livepoll/api/service/PollItemService.kt | 142 +++++--- 9 files changed, 382 insertions(+), 104 deletions(-) create mode 100644 src/main/kotlin/de/livepoll/api/entity/db/PollItemAnswerable.kt diff --git a/.github/scripts/docker-compose.yml b/.github/scripts/docker-compose.yml index e9734dd1..d27c9cb8 100644 --- a/.github/scripts/docker-compose.yml +++ b/.github/scripts/docker-compose.yml @@ -34,7 +34,7 @@ services: LIVE_POLL_JWT_AUTH_COOKIE_NAME: jaQ83KNSgsquPRGy8b8HraKB5kUQC3 LIVE_POLL_JWT_COOKIE_KEY_VALUE: 4Vdx8rg84SvGYbhNwdsS8s6ZwH94LC LIVE_POLL_JWT_SECRET: xyqCc3h3Z2DSYvz7ELEQMsv4U4b7jg - LIVE_POLL_HTTPS_ENABLED: "true" + LIVE_POLL_HTTPS_ENABLED: "false" LIVE_POLL_HTTPS_CERT_PASSWORD: ${LIVE_POLL_HTTPS_CERT_PASSWORD} LIVE_POLL_POSTMAN: "true" server.error.include-message: "always" diff --git a/postman/Livepoll.postman_collection.json b/postman/Livepoll.postman_collection.json index 85e0627d..912a8a7f 100644 --- a/postman/Livepoll.postman_collection.json +++ b/postman/Livepoll.postman_collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "e270b048-0307-4376-9110-ccde318ad785", + "_postman_id": "a3702706-f5ee-4e7f-b11d-062f6af6c688", "name": "Livepoll", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, @@ -87,15 +87,15 @@ "listen": "test", "script": { "exec": [ - "pm.test(\"Status code is 200\", function () {\r", - " pm.response.to.have.status(200);\r", - "});\r", + "//pm.test(\"Status code is 200\", function () {\r", + "// pm.response.to.have.status(200);\r", + "//});\r", "\r", - "const urlPollId =\"localhost:8080/v1/polls/\";\r", - "pm.sendRequest(urlPollId, function(error, response){\r", - " let resBody = JSON.parse(new Buffer.from(response.stream).toString())\r", - " pm.globals.set(\"poll-id\", resBody[0].id)\r", - "})" + "//const urlPollId =\"localhost:8080/v1/polls/\";\r", + "//pm.sendRequest(urlPollId, function(error, response){\r", + "// let resBody = JSON.parse(new Buffer.from(response.stream).toString())\r", + "// pm.globals.set(\"poll-id\", resBody[0].id)\r", + "//});" ], "type": "text/javascript" } @@ -338,7 +338,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\r\n \"name\": \"update\",\r\n \"startDate\": \"1610705932\",\r\n \"endDate\":\"1610705932\",\r\n \"slug\": \"1234\",\r\n \"currentItem\": 126\r\n \r\n}", + "raw": "{\r\n \"name\": \"update\",\r\n \"startDate\": \"1610705932\",\r\n \"endDate\":\"1610705932\",\r\n \"slug\": \"1234\",\r\n \"currentItem\": 1\r\n}", "options": { "raw": { "language": "json" @@ -416,7 +416,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-multiplechoice-question\",\r\n \"answers\": [\"postman-multiplechoice-answer-1\", \"postman-multiplechoice-answer-2\"]\r\n}", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-multiplechoice-question\",\r\n \"selectionOptions\": [\"postman-multiplechoice-answer-1\", \"postman-multiplechoice-answer-2\"]\r\n}", "options": { "raw": { "language": "json" @@ -457,7 +457,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-multiplechoice-question-update\",\r\n \"position\": 3,\r\n \"answers\": [\"postman-multiplechoice-answer-1-update\", \"postman-multiplechoice-answer-2-update\"]\r\n}", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"position\": 3,\r\n \"question\": \"postman-multiplechoice-question-update\",\r\n \"selectionOptions\": [\"postman-multiplechoice-answer-1-update\", \"postman-multiplechoice-answer-2-update\"]\r\n}", "options": { "raw": { "language": "json" @@ -499,7 +499,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-quiz-question\",\r\n \"answers\": [ \"correct option\", \"wrong option\", \"wrong option\"]\r\n}", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-quiz-question\",\r\n \"selectionOptions\": [ \"correct option\", \"wrong option\", \"wrong option\"]\r\n}", "options": { "raw": { "language": "json" @@ -540,7 +540,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"position\": 1,\r\n \"question\": \"postman-quiz-question-update\",\r\n \"answers\": [ \"correct option update\", \"wrong option update\", \"wrong option update\"]\r\n}", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"position\": 1,\r\n \"question\": \"postman-quiz-question-update\",\r\n \"selectionOptions\": [ \"correct option update\", \"wrong option update\", \"wrong option update\"]\r\n}", "options": { "raw": { "language": "json" @@ -712,7 +712,7 @@ "pm.globals.unset(\"poll-items-counter\");\r", "\r", "// Just for newman since we don't have our Livepoll Dev environment there\r", - "pm.environment.set('base-url', 'https://localhost:8080');\r", + "pm.environment.set('base-url', 'http://localhost:8080');\r", "" ], "type": "text/javascript" @@ -948,7 +948,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-multiplechoice-question\",\r\n \"answers\": [\"postman-multiplechoice-answer-1\", \"postman-multiplechoice-answer-2\"]\r\n}", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-multiplechoice-question\",\r\n \"selectionOptions\": [\"postman-multiplechoice-answer-1\", \"postman-multiplechoice-answer-2\"]\r\n}", "options": { "raw": { "language": "json" @@ -1003,7 +1003,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-quiz-question\",\r\n \"answers\": [ \"correct option\", \"wrong option\", \"wrong option\"]\r\n}", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-quiz-question\",\r\n \"selectionOptions\": [ \"correct option\", \"wrong option\", \"wrong option\"]\r\n}", "options": { "raw": { "language": "json" @@ -1212,7 +1212,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"name\": \"{{$randomProductName}}\",\r\n \"startDate\": \"1669892400000\",\r\n \"endDate\":\"1669892400000\",\r\n \"slug\": \"12345\",\r\n \"currentItem\": {{poll-item-id}}\r\n \r\n}", + "raw": "{\r\n \"name\": \"{{$randomProductName}}\",\r\n \"startDate\": \"1669892400000\",\r\n \"endDate\":\"1669892400000\",\r\n \"slug\": \"12345\",\r\n \"currentItem\": \"{{poll-item-id}}\"\r\n}", "options": { "raw": { "language": "json" @@ -1240,26 +1240,32 @@ "listen": "test", "script": { "exec": [ + "const baseUrl = pm.environment.get('base-url');\r", + "const pollId = pm.globals.get('poll-id');\r", + "const cookies = pm.environment.get('cookies');\r", + "\r", + "// Poll item ids\r", + "const multipleChoiceId = pm.globals.get('poll-item-id-multiple-choice');\r", + "const quizId = pm.globals.get('poll-item-id-quiz');\r", + "const openTextId = pm.globals.get('poll-item-id-open-text');\r", + "\r", + "\r", "pm.test(\"Status code is 200\", function () {\r", " pm.response.to.have.status(200);\r", "});\r", "\r", - "pm.test(\"Multiple choice item got moved from position 1 to position 3\", () => {\r", - " const baseUrl = pm.environment.get('base-url');\r", - " const pollId = pm.globals.get('poll-id');\r", "\r", + "// ---------------------- Position -------------------------------\r", + "pm.test(\"Multiple choice item got moved from position 1 to position 3\", () => {\r", " pm.sendRequest({\r", " url: `${baseUrl}/v1/polls/${pollId}/poll-items`,\r", " method: 'GET',\r", - " header: `Cookie:${pm.globals.get('cookies')}`\r", + " header: {\r", + " 'Cookie': cookies\r", + " }\r", " }, function (err, response) {\r", " response = response.json();\r", "\r", - " // Poll item Ids\r", - " const multipleChoiceId = pm.globals.get('poll-item-id-multiple-choice');\r", - " const quizId = pm.globals.get('poll-item-id-quiz');\r", - " const openTextId = pm.globals.get('poll-item-id-open-text');\r", - "\r", " // Multiple choice item got moved from position 1 to position 3\r", " pm.expect(response[0].itemId).to.equal(multipleChoiceId);\r", " pm.expect(response[0].position).to.equal(3);\r", @@ -1271,6 +1277,100 @@ " pm.expect(response[2].position).to.equal(2);\r", " });\r", "});\r", + "\r", + "\r", + "// -------------------- Selection options -------------------------------\r", + "pm.test(\"Selection options that already exist are kept\", () => {\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/v1/poll-items/multiple-choice/${multipleChoiceId}`,\r", + " method: 'PUT',\r", + " header: {\r", + " 'Cookie': cookies,\r", + " 'Content-Type': 'application/json'\r", + " },\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " \"pollId\": `${pollId}`,\r", + " \"question\": \"postman-multiplechoice-question-update\",\r", + " \"position\": 3,\r", + " \"selectionOptions\": [\"postman-multiplechoice-answer-1-update\", \"postman-multiplechoice-answer-2-update\"]\r", + " })\r", + " }\r", + " }, function (err, response) {\r", + " response = response.json();\r", + "\r", + " // Selection option already existed and are kept\r", + " pm.expect(response.answers[0].selectionOption).to.equal(\"postman-multiplechoice-answer-1-update\");\r", + " pm.expect(response.answers[1].selectionOption).to.equal(\"postman-multiplechoice-answer-2-update\");\r", + " // TODO: test in conjunction with websockets to make sure that answer count also stays the same\r", + " });\r", + "});\r", + "\r", + "pm.test(\"New selection options are added (with initial answer count of 0)\", () => {\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/v1/poll-items/multiple-choice/${multipleChoiceId}`,\r", + " method: 'PUT',\r", + " header: {\r", + " 'Cookie': cookies,\r", + " 'Content-Type': 'application/json'\r", + " },\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " \"pollId\": `${pollId}`,\r", + " \"question\": \"postman-multiplechoice-question-update\",\r", + " \"position\": 3,\r", + " \"selectionOptions\": [\"new-option\", \"postman-multiplechoice-answer-1-update\", \"postman-multiplechoice-answer-2-update\"]\r", + " })\r", + " }\r", + " }, function (err, response) {\r", + " response = response.json();\r", + "\r", + " // New selection option is added at the end\r", + " pm.expect(response.answers[0].selectionOption).to.equal(\"postman-multiplechoice-answer-1-update\");\r", + " pm.expect(response.answers[1].selectionOption).to.equal(\"postman-multiplechoice-answer-2-update\");\r", + " pm.expect(response.answers[2].selectionOption).to.equal(\"new-option\"); // was first item in the request body selectionOptions\r", + "\r", + " // Initial answer count must equal 0\r", + " pm.expect(response.answers[2].answerCount).to.equal(0)\r", + " });\r", + "});\r", + "\r", + "pm.test(\"Selection option that existed in database but not anymore in update is removed\", () => {\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/v1/poll-items/multiple-choice/${multipleChoiceId}`,\r", + " method: 'PUT',\r", + " header: {\r", + " 'Cookie': cookies,\r", + " 'Content-Type': 'application/json'\r", + " },\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " \"pollId\": `${pollId}`,\r", + " \"question\": \"postman-multiplechoice-question-update\",\r", + " \"position\": 3,\r", + " \"selectionOptions\": [\"postman-multiplechoice-answer-1-update\", \"postman-multiplechoice-answer-2-update\"]\r", + " })\r", + " }\r", + " }, function (err, response) {\r", + " response = response.json();\r", + "\r", + " // New selection option is removed and correct answer updated\r", + " pm.expect(response.answers[0].selectionOption).to.equal(\"postman-multiplechoice-answer-1-update\");\r", + " pm.expect(response.answers[1].selectionOption).to.equal(\"postman-multiplechoice-answer-2-update\");\r", + " });\r", + "});\r", + "" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ "" ], "type": "text/javascript" @@ -1288,7 +1388,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-multiplechoice-question-update\",\r\n \"position\": 3,\r\n \"answers\": [\"postman-multiplechoice-answer-1-update\", \"postman-multiplechoice-answer-2-update\"]\r\n}", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-multiplechoice-question-update\",\r\n \"position\": 3,\r\n \"selectionOptions\": [\"postman-multiplechoice-answer-1-update\", \"postman-multiplechoice-answer-2-update\"]\r\n}", "options": { "raw": { "language": "json" @@ -1317,26 +1417,32 @@ "listen": "test", "script": { "exec": [ + "const baseUrl = pm.environment.get('base-url');\r", + "const pollId = pm.globals.get('poll-id');\r", + "const cookies = pm.environment.get('cookies');\r", + "\r", + "// Poll item ids\r", + "const multipleChoiceId = pm.globals.get('poll-item-id-multiple-choice');\r", + "const quizId = pm.globals.get('poll-item-id-quiz');\r", + "const openTextId = pm.globals.get('poll-item-id-open-text');\r", + "\r", + "\r", "pm.test(\"Status code is 200\", function () {\r", " pm.response.to.have.status(200);\r", "});\r", "\r", - "pm.test(\"Quiz item stays at position 1\", () => {\r", - " const baseUrl = pm.environment.get('base-url');\r", - " const pollId = pm.globals.get('poll-id');\r", "\r", + "// ---------------------- Position -------------------------------\r", + "pm.test(\"Quiz item stays at position 1\", () => {\r", " pm.sendRequest({\r", " url: `${baseUrl}/v1/polls/${pollId}/poll-items`,\r", " method: 'GET',\r", - " header: `Cookie:${pm.globals.get('cookies')}`\r", + " header: {\r", + " 'Cookie': cookies\r", + " }\r", " }, function (err, response) {\r", " response = response.json();\r", "\r", - " // Poll item Ids\r", - " const multipleChoiceId = pm.globals.get('poll-item-id-multiple-choice');\r", - " const quizId = pm.globals.get('poll-item-id-quiz');\r", - " const openTextId = pm.globals.get('poll-item-id-open-text');\r", - "\r", " // Quiz item stays at position 1\r", " pm.expect(response[0].itemId).to.equal(multipleChoiceId);\r", " pm.expect(response[0].position).to.equal(3);\r", @@ -1348,6 +1454,121 @@ " pm.expect(response[2].position).to.equal(2);\r", " });\r", "});\r", + "\r", + "\r", + "// -------------------- Selection options -------------------------------\r", + "pm.test(\"Selection options that already exist are kept\", () => {\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/v1/poll-items/quiz/${quizId}`,\r", + " method: 'PUT',\r", + " header: {\r", + " 'Cookie': cookies,\r", + " 'Content-Type': 'application/json'\r", + " },\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " \"pollId\": `${pollId}`,\r", + " \"question\": \"postman-quiz-question-update\",\r", + " \"position\": 1,\r", + " \"selectionOptions\": [ \"correct option update\", \"wrong option update\", \"wrong option update2\"]\r", + " })\r", + " }\r", + " }, function (err, response) {\r", + " response = response.json();\r", + "\r", + " // Selection option already existed and are kept\r", + " pm.expect(response.answers[0].selectionOption).to.equal(\"correct option update\");\r", + " pm.expect(response.answers[0].isCorrect).to.be.true;\r", + " pm.expect(response.answers[1].selectionOption).to.equal(\"wrong option update\");\r", + " pm.expect(response.answers[1].isCorrect).to.be.false;\r", + " pm.expect(response.answers[2].selectionOption).to.equal(\"wrong option update2\");\r", + " pm.expect(response.answers[2].isCorrect).to.be.false;\r", + " // TODO: test in conjunction with websockets to make sure that answer count also stays the same\r", + " });\r", + "});\r", + "\r", + "pm.test(\"New selection options are added (with initial answer count of 0)\", () => {\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/v1/poll-items/quiz/${quizId}`,\r", + " method: 'PUT',\r", + " header: {\r", + " 'Cookie': cookies,\r", + " 'Content-Type': 'application/json'\r", + " },\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " \"pollId\": `${pollId}`,\r", + " \"question\": \"postman-quiz-question-update\",\r", + " \"position\": 1,\r", + " \"selectionOptions\": [ \"correct option update\", \"wrong option update\", \"new wrong option\", \"wrong option update2\"]\r", + " })\r", + " }\r", + " }, function (err, response) {\r", + " response = response.json();\r", + "\r", + " // New selection option is added (at the end, but no order is guaranteed!)\r", + " pm.expect(response.answers[0].selectionOption).to.equal(\"correct option update\");\r", + " pm.expect(response.answers[0].isCorrect).to.be.true;\r", + " pm.expect(response.answers[1].selectionOption).to.equal(\"wrong option update\");\r", + " pm.expect(response.answers[1].isCorrect).to.be.false;\r", + " pm.expect(response.answers[2].selectionOption).to.equal(\"wrong option update2\");\r", + " pm.expect(response.answers[2].isCorrect).to.be.false;\r", + " pm.expect(response.answers[3].selectionOption).to.equal(\"new wrong option\");\r", + " pm.expect(response.answers[3].isCorrect).to.be.false;\r", + "\r", + " // Initial answer count must equal 0\r", + " pm.expect(response.answers[3].answerCount).to.equal(0)\r", + " });\r", + "});\r", + "\r", + "pm.test(\"Selection option that existed in database but not anymore in update is removed\", () => {\r", + " pm.sendRequest({\r", + " url: `${baseUrl}/v1/poll-items/quiz/${quizId}`,\r", + " method: 'PUT',\r", + " header: {\r", + " 'Cookie': cookies,\r", + " 'Content-Type': 'application/json'\r", + " },\r", + " body: {\r", + " mode: 'raw',\r", + " raw: JSON.stringify({\r", + " \"pollId\": `${pollId}`,\r", + " \"question\": \"postman-quiz-question-update\",\r", + " \"position\": 1,\r", + " \"selectionOptions\": [\"wrong option update\", \"new wrong option\", \"wrong option update2\"]\r", + " })\r", + " }\r", + " }, function (err, response) {\r", + " response = response.json();\r", + " \r", + " // First option is removed. As the first item should always represent the correct option\r", + " // a new item is the correct option now (only possible since the \"correct option update\" had an answer count of 0)\r", + " // Sorted according to their ids:\r", + " pm.expect(response.answers.length).to.be.equal(3); // only three elements left\r", + " pm.expect(response.answers[0].selectionOption).to.equal(\"wrong option update\");\r", + " pm.expect(response.answers[0].isCorrect).to.be.true;\r", + " pm.expect(response.answers[1].selectionOption).to.equal(\"wrong option update2\");\r", + " pm.expect(response.answers[1].isCorrect).to.be.false;\r", + " pm.expect(response.answers[2].selectionOption).to.equal(\"new wrong option\");\r", + " pm.expect(response.answers[2].isCorrect).to.be.false;\r", + " });\r", + "});\r", + "\r", + "// TODO: have an item with answer count > 0 and test\r", + "// 1) selection option is kept with answer alongside the answer count\r", + "// 2) can't mark another item as correct if the current correct item has an answer count > 0\r", + "// --> check this manually right now changing values manually in the database\r", + "" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ "" ], "type": "text/javascript" @@ -1365,7 +1586,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"position\": 1,\r\n \"question\": \"postman-quiz-question-update\",\r\n \"answers\": [ \"correct option update\", \"wrong option update\", \"wrong option update\"]\r\n}", + "raw": "{\r\n \"pollId\": \"{{poll-id}}\",\r\n \"question\": \"postman-quiz-question-update\",\r\n \"position\": 1,\r\n \"selectionOptions\": [ \"correct option update\", \"wrong option update\", \"wrong option update2\"]\r\n}", "options": { "raw": { "language": "json" @@ -1394,10 +1615,15 @@ "listen": "test", "script": { "exec": [ + "const cookies = pm.environment.get('cookies');\r", + "\r", + "\r", "pm.test(\"Status code is 200\", function () {\r", " pm.response.to.have.status(200);\r", "});\r", "\r", + "\r", + "// ---------------------- Position -------------------------------\r", "pm.test(\"Open text item got moved from position 2 to position 1\", () => {\r", " const baseUrl = pm.environment.get('base-url');\r", " const pollId = pm.globals.get('poll-id');\r", @@ -1405,7 +1631,9 @@ " pm.sendRequest({\r", " url: `${baseUrl}/v1/polls/${pollId}/poll-items`,\r", " method: 'GET',\r", - " header: `Cookie:${pm.globals.get('cookies')}`\r", + " header: {\r", + " \"Cookie\": cookies\r", + " }\r", " }, function (err, response) {\r", " response = response.json();\r", "\r", diff --git a/src/main/kotlin/de/livepoll/api/entity/db/MultipleChoiceItem.kt b/src/main/kotlin/de/livepoll/api/entity/db/MultipleChoiceItem.kt index ac18fae6..f5277bd7 100644 --- a/src/main/kotlin/de/livepoll/api/entity/db/MultipleChoiceItem.kt +++ b/src/main/kotlin/de/livepoll/api/entity/db/MultipleChoiceItem.kt @@ -20,6 +20,6 @@ class MultipleChoiceItem( var allowBlankField: Boolean, @OneToMany(mappedBy = "multipleChoiceItem", cascade = [CascadeType.ALL], orphanRemoval = true) - var answers: MutableList + override var answers: MutableList -) : PollItem(id, poll, question, position, PollItemType.MULTIPLE_CHOICE) +) : PollItemAnswerable(id, poll, question, position, PollItemType.MULTIPLE_CHOICE, answers) diff --git a/src/main/kotlin/de/livepoll/api/entity/db/PollItemAnswerable.kt b/src/main/kotlin/de/livepoll/api/entity/db/PollItemAnswerable.kt new file mode 100644 index 00000000..194b0d19 --- /dev/null +++ b/src/main/kotlin/de/livepoll/api/entity/db/PollItemAnswerable.kt @@ -0,0 +1,11 @@ +package de.livepoll.api.entity.db + +abstract class PollItemAnswerable( + id: Long, + poll: Poll, + question: String, + position: Int, + type: PollItemType, + open val answers: MutableList + +) : PollItem(id, poll, question, position, type) diff --git a/src/main/kotlin/de/livepoll/api/entity/db/QuizItem.kt b/src/main/kotlin/de/livepoll/api/entity/db/QuizItem.kt index 1407036a..43726c73 100644 --- a/src/main/kotlin/de/livepoll/api/entity/db/QuizItem.kt +++ b/src/main/kotlin/de/livepoll/api/entity/db/QuizItem.kt @@ -17,6 +17,6 @@ class QuizItem( question: String, @OneToMany(mappedBy = "quizItem", cascade = [CascadeType.ALL], orphanRemoval = true) - var answers: MutableList + override var answers: MutableList -) : PollItem(id, poll, question, position, PollItemType.QUIZ) \ No newline at end of file +) : PollItemAnswerable(id, poll, question, position, PollItemType.QUIZ, answers) diff --git a/src/main/kotlin/de/livepoll/api/entity/db/SelectionOptionAnswer.kt b/src/main/kotlin/de/livepoll/api/entity/db/SelectionOptionAnswer.kt index 2ba8e6bc..a7de4de9 100644 --- a/src/main/kotlin/de/livepoll/api/entity/db/SelectionOptionAnswer.kt +++ b/src/main/kotlin/de/livepoll/api/entity/db/SelectionOptionAnswer.kt @@ -1,8 +1,7 @@ package de.livepoll.api.entity.db -interface SelectionOptionAnswer{ +interface SelectionOptionAnswer { val id: Long var answerCount: Int val selectionOption: String - -} \ No newline at end of file +} diff --git a/src/main/kotlin/de/livepoll/api/entity/dto/MultipleChoiceItemDtoIn.kt b/src/main/kotlin/de/livepoll/api/entity/dto/MultipleChoiceItemDtoIn.kt index 08b22f67..24664126 100644 --- a/src/main/kotlin/de/livepoll/api/entity/dto/MultipleChoiceItemDtoIn.kt +++ b/src/main/kotlin/de/livepoll/api/entity/dto/MultipleChoiceItemDtoIn.kt @@ -5,7 +5,7 @@ open class MultipleChoiceItemDtoIn( question: String, val allowMultipleAnswers: Boolean, val allowBlankField: Boolean, - val answers: List, + val selectionOptions: List, ) : PollItemDtoIn(pollId, question) class MultipleChoiceItemWithPositionDtoIn( @@ -13,6 +13,6 @@ class MultipleChoiceItemWithPositionDtoIn( question: String, allowMultipleAnswers: Boolean, allowBlankField: Boolean, - answers: List, + selectionOptions: List, val position: Int -) : MultipleChoiceItemDtoIn(pollId, question, allowMultipleAnswers, allowBlankField, answers) +) : MultipleChoiceItemDtoIn(pollId, question, allowMultipleAnswers, allowBlankField, selectionOptions) diff --git a/src/main/kotlin/de/livepoll/api/entity/dto/QuizItemDtoIn.kt b/src/main/kotlin/de/livepoll/api/entity/dto/QuizItemDtoIn.kt index 648bc1d8..549fbb37 100644 --- a/src/main/kotlin/de/livepoll/api/entity/dto/QuizItemDtoIn.kt +++ b/src/main/kotlin/de/livepoll/api/entity/dto/QuizItemDtoIn.kt @@ -3,12 +3,12 @@ package de.livepoll.api.entity.dto open class QuizItemDtoIn( pollId: Long, question: String, - val answers: List + val selectionOptions: List ) : PollItemDtoIn(pollId, question) class QuizItemWithPositionDtoIn( pollId: Long, question: String, - answers: List, + selectionOptions: List, val position: Int -) : QuizItemDtoIn(pollId, question, answers) +) : QuizItemDtoIn(pollId, question, selectionOptions) diff --git a/src/main/kotlin/de/livepoll/api/service/PollItemService.kt b/src/main/kotlin/de/livepoll/api/service/PollItemService.kt index 0430cf53..c36b9722 100644 --- a/src/main/kotlin/de/livepoll/api/service/PollItemService.kt +++ b/src/main/kotlin/de/livepoll/api/service/PollItemService.kt @@ -86,7 +86,7 @@ class PollItemService { ) // Multiple choice item answers multipleChoiceItem.answers = - item.answers.map { MultipleChoiceItemAnswer(0, multipleChoiceItem, it, 0) }.toMutableList() + item.selectionOptions.map { MultipleChoiceItemAnswer(0, multipleChoiceItem, it, 0) }.toMutableList() multipleChoiceItemRepository.saveAndFlush(multipleChoiceItem) multipleChoiceItem.answers.forEach { multipleChoiceItemAnswerRepository.saveAndFlush(it) } return multipleChoiceItem.toDtoOut() @@ -111,7 +111,7 @@ class PollItemService { mutableListOf() ) // Quiz item answers - quizItem.answers = item.answers.mapIndexed { index, element -> + quizItem.answers = item.selectionOptions.mapIndexed { index, element -> QuizItemAnswer(0, quizItem, element, index == 0, 0) }.toMutableList() @@ -145,7 +145,7 @@ class PollItemService { //-------------------------------------------- Update -------------------------------------------------------------- /** - * Move poll item to another position while updating the positions of other poll items in the poll.
+ * Move poll item to another position while updating the positions of other poll items in the poll. * * This method works in-place and will adjust the poll items list.
* Old and new position are counted from 1 onwards, NOT from 0! @@ -179,6 +179,81 @@ class PollItemService { pollItemRepository.saveAndFlush(pollItems[oldPos - 1]) } + /** + * Update the answers of a poll item according to an update list of selection options (strings). + * This method works in-place and will adjust the poll item answers list. + * + * Requirements this algorithm has to fulfill: + * + * - Selection option already exists (in an answer in the database) + * -> Keep the existing one (including the answer count) + * + * - Selection option is new + * -> Add a new answer to this poll item + * + * - Selection option existed in the database but not anymore in the update + * -> Remove the answer that contained this selection option + */ + fun updateAnswers(item: PollItemAnswerable, selectionOptionsUpdate: List) { + val selectionOptionsExisting = item.answers.map { it.selectionOption } + // --- Example + // e indicates: "existing" + // u indicates: "update" + // selection_option_existing ["Ae", "Be", "Ce"] + // selection_option_update ["Au", "Bu", "Du"] + // selection_option_result ["Ae", "Be", "Du"] + // note that Ce is gone + // note that Ae/Be are used in favor of Au/Bu since Ae/Be might include answer counts > 0 + + // --- Checks + if (item is QuizItem) { + // In the updated list of strings, the first selection option is supposed to be the correct one + // This might lead to marking an item that was correct before as incorrect. + // This is only allowed if the previously correct item has an answer count of 0. + // Otherwise we throw an error. + if (item.answers.isNotEmpty() && selectionOptionsUpdate.isNotEmpty()) { + if (item.answers[0].answerCount != 0) { + if (item.answers[0].selectionOption != selectionOptionsUpdate[0]) { + val msg = + "This update would mark an existing correct selection option whose answer count is " + + "greater than 0 as incorrect. Aborting the update." + throw ResponseStatusException(HttpStatus.BAD_REQUEST, msg) + } + } + } + } + + // --- Removing + // Remove answer in db whose whose selection option is not included in the selection options from the update + // Remove (selection_option_existing \ selection_option_update) + // in the example: remove Ce + item.answers.removeIf { !selectionOptionsUpdate.contains(it.selectionOption) } + + // --- Adding + // Add selection option (wrapped as an answer) to db if it is not included in any answer from the db + // Add (selection_option_update \ selection_option_existing) + // in the example: add Du + val toAddAnswers = selectionOptionsUpdate + .filter { !selectionOptionsExisting.contains(it) } + // wrap selection option string as answerable poll item + .map { + if (item is MultipleChoiceItem) { + MultipleChoiceItemAnswer(0, item, it, 0) + } else if (item is QuizItem) { + QuizItemAnswer(0, item, it, false, 0) // the correct item is updated later + } else { + // Should never happen + throw ResponseStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, + "Only multiple choice and quiz items allow for answers" + ) + } + } + // Don't know why Kotlin wants a Collection here. Might be a bug in Kotlin. + // If you know a better approach, please fix this. + item.answers.addAll(toAddAnswers as Collection) + } + fun updateMultipleChoiceItem( pollItemId: Long, pollItem: MultipleChoiceItemWithPositionDtoIn @@ -186,28 +261,7 @@ class PollItemService { multipleChoiceItemRepository.findById(pollItemId) .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Poll item not found") } .run { - val newAnswers = pollItem.answers.toMutableList() - val removeAnswer = mutableListOf() - this.answers.forEach { - if (it.answerCount != 0) { - if (pollItem.answers.contains(it.selectionOption)) { - newAnswers.remove(it.selectionOption) - } else { - this.answers.remove(it) - } - } else { - if (!pollItem.answers.contains(it.selectionOption)) { - removeAnswer.add(it) - } - } - } - removeAnswer.forEach { - this.answers.remove(it) - } - newAnswers.forEach { - this.answers.add(MultipleChoiceItemAnswer(0, this, it, 0)) - } - + updateAnswers(this, pollItem.selectionOptions) this.question = pollItem.question this.allowMultipleAnswers = pollItem.allowMultipleAnswers this.allowBlankField = pollItem.allowBlankField @@ -219,35 +273,21 @@ class PollItemService { } fun updateQuizItem(pollItemId: Long, pollItem: QuizItemWithPositionDtoIn): QuizItemDtoOut { + if (pollItem.selectionOptions.size < 2) { + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Quiz item must include at least two items (correct selection option and at least one wrong selection option)" + ) + } + quizItemRepository.findById(pollItemId) .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Poll item not found") } .run { - val newAnswers = pollItem.answers.toMutableList() - val removeAnswer = mutableListOf() - this.answers.forEach { - if (it.answerCount != 0) { - if (pollItem.answers.contains(it.selectionOption)) { - newAnswers.remove(it.selectionOption) - it.isCorrect = false - } else { - this.answers.remove(it) - } - } else { - if (!pollItem.answers.contains(it.selectionOption)) { - removeAnswer.add(it) - } - } - } - removeAnswer.forEach { - this.answers.remove(it) - } - newAnswers.forEach { - this.answers.add(QuizItemAnswer(0, this, it, false, 0)) - } - val newCorrectOne = this.answers.find { it.selectionOption == pollItem.answers[0] }!! - if (newCorrectOne.answerCount == 0) { - this.answers.find { it.selectionOption == pollItem.answers[0] }!!.isCorrect = true - } + updateAnswers(this, pollItem.selectionOptions) + + // Update correct answer + val newCorrectIndex = this.answers.indexOfFirst { it.selectionOption == pollItem.selectionOptions[0] } + this.answers[newCorrectIndex].isCorrect = true this.question = pollItem.question movePollItem(this.position, pollItem.position, this.poll.pollItems) From 6ba86482467cd1dd117ffc235d17e78ad0a0b596 Mon Sep 17 00:00:00 2001 From: Niklas-23 <65356992+Niklas-23@users.noreply.github.com> Date: Mon, 24 May 2021 17:04:12 +0200 Subject: [PATCH 21/25] Presentation improvements (#102) * Next item endpoint * Update scheduled poll * Fix deleting active poll item * Update Livepoll.postman_collection.json * Change PollDtoOut to PollItemDtoOut --- postman/Livepoll.postman_collection.json | 120 +++++++++++++++--- .../livepoll/api/controller/PollController.kt | 12 ++ .../livepoll/api/service/PollItemService.kt | 8 ++ .../de/livepoll/api/service/PollService.kt | 43 ++++++- 4 files changed, 161 insertions(+), 22 deletions(-) diff --git a/postman/Livepoll.postman_collection.json b/postman/Livepoll.postman_collection.json index 912a8a7f..4d71d9ce 100644 --- a/postman/Livepoll.postman_collection.json +++ b/postman/Livepoll.postman_collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "a3702706-f5ee-4e7f-b11d-062f6af6c688", + "_postman_id": "4e9192be-d8d9-4d36-9b77-c8d3c03eddb4", "name": "Livepoll", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, @@ -87,15 +87,11 @@ "listen": "test", "script": { "exec": [ - "//pm.test(\"Status code is 200\", function () {\r", - "// pm.response.to.have.status(200);\r", - "//});\r", - "\r", - "//const urlPollId =\"localhost:8080/v1/polls/\";\r", - "//pm.sendRequest(urlPollId, function(error, response){\r", - "// let resBody = JSON.parse(new Buffer.from(response.stream).toString())\r", - "// pm.globals.set(\"poll-id\", resBody[0].id)\r", - "//});" + "const urlPollId =\"localhost:8080/v1/polls/\";\r", + "pm.sendRequest(urlPollId, function(error, response){\r", + " let resBody = JSON.parse(new Buffer.from(response.stream).toString())\r", + " pm.globals.set(\"poll-id\", resBody[0].id)\r", + "});" ], "type": "text/javascript" } @@ -174,7 +170,9 @@ "exec": [ "pm.test(\"Status code is 201\", function () {\r", " pm.response.to.have.status(201);\r", - "});" + "});\r", + "\r", + "pm.globals.set(\"poll-id\", pm.response.json().id)" ], "type": "text/javascript" } @@ -358,6 +356,26 @@ } }, "response": [] + }, + { + "name": "Next presentation item", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base-url}}/v1/polls/{{poll-id}}/next-item", + "host": [ + "{{base-url}}" + ], + "path": [ + "v1", + "polls", + "{{poll-id}}", + "next-item" + ] + } + }, + "response": [] } ] }, @@ -706,10 +724,14 @@ "listen": "prerequest", "script": { "exec": [ - "pm.globals.unset(\"poll-id\");\r", - "pm.globals.unset(\"poll-item-id\");\r", - "pm.globals.unset(\"poll-items\");\r", - "pm.globals.unset(\"poll-items-counter\");\r", + "pm.globals.unset(\"poll-id\")\r", + "pm.globals.unset(\"poll-item-id\")\r", + "pm.globals.unset(\"poll-item-id-multiple-choice\")\r", + "pm.globals.unset(\"poll-item-id-open-text\")\r", + "pm.globals.unset(\"poll-item-id-quiz\")\r", + "pm.globals.unset(\"poll-items\")\r", + "pm.globals.unset(\"poll-items-counter\")\r", + "pm.globals.unset(\"cookies\")\r", "\r", "// Just for newman since we don't have our Livepoll Dev environment there\r", "pm.environment.set('base-url', 'http://localhost:8080');\r", @@ -1120,6 +1142,51 @@ }, "response": [] }, + { + "name": "Next presentation item", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {\r", + " pm.response.to.have.status(200);\r", + "});\r", + "\r", + "if(pm.response.text() == \"Poll over\"){\r", + " postman.setNextRequest(\"Get poll item\")\r", + "}else{\r", + " postman.setNextRequest(\"Next presentation item\")\r", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "Cookie", + "value": "{{cookies}}", + "type": "text" + } + ], + "url": { + "raw": "{{base-url}}/v1/polls/{{poll-id}}/next-item", + "host": [ + "{{base-url}}" + ], + "path": [ + "v1", + "polls", + "{{poll-id}}", + "next-item" + ] + } + }, + "response": [] + }, { "name": "Get poll item", "event": [ @@ -1702,13 +1769,17 @@ "pm.test(\"Status code is 200\", function () {\r", " pm.response.to.have.status(200);\r", "});\r", - "/*const url = \"localhost:8080/v1/poll-items/\" + pm.globals.get(\"poll-item-id\");\r", - "pm.sendRequest(url, function(error, response) {\r", + "\r", + "pm.sendRequest({\r", + " url: \"localhost:8080/v1/poll-items/\" + pm.globals.get(\"poll-item-id\"),\r", + " method: 'GET',\r", + " headers: 'Cookie:'+pm.environment.get(\"cookies\")\r", + " }, function(error, response) {\r", " pm.test(\"Poll item does not exist anymore\", function() {\r", " pm.expect(response).to.have.property('code', 403)\r", " pm.expect(response).to.have.property('status', 'Forbidden')\r", " })\r", - "})*/" + "})" ], "type": "text/javascript" } @@ -1747,16 +1818,23 @@ "pm.test(\"Status code is 200\", function () {\r", " pm.response.to.have.status(200);\r", "});\r", - "/*const url = \"localhost:8080/v1/polls/\" + pm.globals.get(\"poll-id\");\r", - "pm.sendRequest(url, function(error, response) {\r", + "\r", + "pm.sendRequest({\r", + " url: \"localhost:8080/v1/polls/\" + pm.globals.get(\"poll-id\"),\r", + " method: 'GET',\r", + " headers: 'Cookie:'+pm.environment.get(\"cookies\")\r", + " }, function(error, response) {\r", " pm.test(\"Poll item does not exist anymore\", function() {\r", " pm.expect(response).to.have.property('code', 403)\r", " pm.expect(response).to.have.property('status', 'Forbidden')\r", " })\r", - "})*/\r", + "})\r", "\r", "pm.globals.unset(\"poll-id\")\r", "pm.globals.unset(\"poll-item-id\")\r", + "pm.globals.unset(\"poll-item-id-multiple-choice\")\r", + "pm.globals.unset(\"poll-item-id-open-text\")\r", + "pm.globals.unset(\"poll-item-id-quiz\")\r", "pm.globals.unset(\"poll-items\")\r", "pm.globals.unset(\"poll-items-counter\")\r", "pm.globals.unset(\"cookies\")" diff --git a/src/main/kotlin/de/livepoll/api/controller/PollController.kt b/src/main/kotlin/de/livepoll/api/controller/PollController.kt index cf574618..ddfb1d07 100644 --- a/src/main/kotlin/de/livepoll/api/controller/PollController.kt +++ b/src/main/kotlin/de/livepoll/api/controller/PollController.kt @@ -54,6 +54,18 @@ class PollController( return pollService.getPollItemsForPoll(pollId) } + @ApiOperation(value = "Get next poll item for presentation", tags = ["Poll presentation"]) + @GetMapping("/{id}/next-item") + fun getNextPollItem(@PathVariable(name = "id") pollId: Long, @AuthenticationPrincipal user: User): ResponseEntity<*> { + accountService.checkAuthorizationByPollId(pollId) + val item = pollService.getNextPollItem(pollId) + return if (item!=null){ + ResponseEntity.ok(item) + }else{ + ResponseEntity.ok("Poll over") + } + } + //-------------------------------------------- Update -------------------------------------------------------------- @ApiOperation(value = "Update slug", tags = ["Poll"]) diff --git a/src/main/kotlin/de/livepoll/api/service/PollItemService.kt b/src/main/kotlin/de/livepoll/api/service/PollItemService.kt index c36b9722..edceac81 100644 --- a/src/main/kotlin/de/livepoll/api/service/PollItemService.kt +++ b/src/main/kotlin/de/livepoll/api/service/PollItemService.kt @@ -59,6 +59,14 @@ class PollItemService { } fun deleteItem(itemId: Long) { + pollItemRepository.findById(itemId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Poll item not found") + }.run { + val poll = this.poll + if (poll.currentItem == itemId) { + poll.currentItem = null + } + } pollItemRepository.deleteById(itemId) } diff --git a/src/main/kotlin/de/livepoll/api/service/PollService.kt b/src/main/kotlin/de/livepoll/api/service/PollService.kt index 685df675..d469c1fa 100644 --- a/src/main/kotlin/de/livepoll/api/service/PollService.kt +++ b/src/main/kotlin/de/livepoll/api/service/PollService.kt @@ -12,8 +12,10 @@ import de.livepoll.api.util.quartz.StartPollPresentationJob import de.livepoll.api.util.quartz.StopPollPresentationJob import de.livepoll.api.util.toDtoOut import org.quartz.JobKey +import org.quartz.TriggerKey import org.springframework.dao.EmptyResultDataAccessException import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity import org.springframework.scheduling.quartz.SchedulerFactoryBean import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -113,7 +115,7 @@ class PollService( }.run { this.name = poll.name if (poll.startDate != null && poll.endDate != null) { - schedulePoll(pollId, poll.startDate, poll.endDate) + updateScheduledPoll(pollId, poll.startDate, poll.endDate) this.startDate = poll.startDate this.endDate = poll.endDate } else if (poll.startDate == null && poll.endDate == null) { @@ -130,6 +132,30 @@ class PollService( } } + fun getNextPollItem(pollId: Long): PollItemDtoOut? { + pollRepository.findById(pollId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "This poll does not exist") + }.run { + pollItems.sortBy { it.position } + if (currentItem == null) { + this.currentItem = pollItems[0].id + } else if (currentItem == pollItems.last().id) { + this.currentItem = null + } else { + val oldItem = pollItems.find { it.id == currentItem } + val newItem = pollItems.find { it.position == requireNotNull(oldItem).position + 1 } + this.currentItem = requireNotNull(newItem).id + } + webSocketService.sendCurrentItem(this.slug, this.id, this.currentItem) + pollRepository.saveAndFlush(this) + return if (this.currentItem != null) { + pollItemService.getPollItem(this.currentItem!!) + }else{ + null + } + } + } + fun isSlugUnique(slug: String) = pollRepository.findBySlug(slug) == null private fun schedulePoll(pollId: Long, startDate: Date, stopDate: Date) { @@ -144,7 +170,22 @@ class PollService( val triggerStop = jobScheduleCrator.createSimpleTrigger("stop-poll-trigger-" + pollId.toString(), stopDate) schedulerFactory.`object`!!.scheduleJob(jobDetailStop, triggerStop) } + } + fun updateScheduledPoll(pollId: Long, startDate: Date, stopDate: Date) { + if (startDate.before(GregorianCalendar.getInstance().time) || stopDate.before(GregorianCalendar.getInstance().time)) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Poll was not planned because start or end date is in the past") + } else { + val jobNameStart: String = "start-poll-trigger-" + pollId + val jobNameStop: String = "stop-poll-trigger-" + pollId + val triggerStart = jobScheduleCrator.createSimpleTrigger(jobNameStart, startDate) + val triggerStop = jobScheduleCrator.createSimpleTrigger(jobNameStop, stopDate) + val returnDate = schedulerFactory.`object`!!.rescheduleJob(TriggerKey.triggerKey(jobNameStart), triggerStart) + schedulerFactory.`object`!!.rescheduleJob(TriggerKey.triggerKey(jobNameStop), triggerStop) + if(returnDate == null){ + schedulePoll(pollId, startDate, stopDate) + } + } } @Transactional From cf457b1f9537636cedf95b0214b28058a8480668 Mon Sep 17 00:00:00 2001 From: Marc Auberer Date: Mon, 24 May 2021 18:37:17 +0200 Subject: [PATCH 22/25] Reflect @Splines username in codeowners config --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a9a3a11c..a2f7a350 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -5,4 +5,4 @@ # For more details, read the following article on GitHub: https://help.github.com/articles/about-codeowners/. # These are the default owners for the whole content of this repository. The default owners are automatically added as reviewers when you open a pull request, unless different owners are specified in the file. -* @Ordinateur-Hack @Niklas-23 \ No newline at end of file +* @Splines @Niklas-23 From 22374cc27b68db995003654dcf2c2736179f045b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 May 2021 18:42:43 +0200 Subject: [PATCH 23/25] Bump kotlin.version from 1.5.0 to 1.5.10 (#103) Bumps `kotlin.version` from 1.5.0 to 1.5.10. Updates `kotlin-maven-allopen` from 1.5.0 to 1.5.10 Updates `kotlin-maven-noarg` from 1.5.0 to 1.5.10 Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index a1de3cd3..ac9dbc1f 100644 --- a/pom.xml +++ b/pom.xml @@ -16,7 +16,7 @@ 11 - 1.5.0 + 1.5.10 1.4.32 From ec4670188dfbfdd46639a658606eb230e7677456 Mon Sep 17 00:00:00 2001 From: Niklas-23 <65356992+Niklas-23@users.noreply.github.com> Date: Mon, 7 Jun 2021 16:41:02 +0200 Subject: [PATCH 24/25] Websocket improvements and documentation (#104) * Subscription interceptor for websocket presentation endpoint * Fix bug when the poll name contains a space * Websocket exceptions * Throw error when changing slug failed * Wrap error message in JSON structure * Fix update slug bug * Return pollId instead of error message when poll was not started yet * Send current item to presentation websocket * Add java doc comments * Add java doc for account and websocket service * Add error method for websocket * Use JSON syntax and check for infinite loop in the Postman tests * Reformat PollController * Reformat controller and services (using the IntelliJ Ctrl+Alt+L shortcut) * Replace deprecated call to getOne() * Simplify and reformat code * Fix spelling mistakes Co-authored-by: Marc Auberer Co-authored-by: Splines --- postman/Livepoll.postman_collection.json | 51 ++-- .../livepoll/api/controller/PollController.kt | 17 +- .../api/controller/PollItemController.kt | 19 +- .../api/controller/WebSocketController.kt | 4 +- .../de/livepoll/api/service/AccountService.kt | 48 +++- .../api/service/JwtUserDetailsService.kt | 1 + .../livepoll/api/service/PollItemService.kt | 54 ++++ .../de/livepoll/api/service/PollService.kt | 245 ++++++++++++------ .../livepoll/api/service/WebSocketService.kt | 26 +- .../api/util/websocket/SubscribeListener.kt | 35 ++- 10 files changed, 388 insertions(+), 112 deletions(-) diff --git a/postman/Livepoll.postman_collection.json b/postman/Livepoll.postman_collection.json index 4d71d9ce..780494bf 100644 --- a/postman/Livepoll.postman_collection.json +++ b/postman/Livepoll.postman_collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "4e9192be-d8d9-4d36-9b77-c8d3c03eddb4", + "_postman_id": "63c95483-1621-437e-8135-14b5c87cd4c8", "name": "Livepoll", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, @@ -724,14 +724,15 @@ "listen": "prerequest", "script": { "exec": [ - "pm.globals.unset(\"poll-id\")\r", - "pm.globals.unset(\"poll-item-id\")\r", - "pm.globals.unset(\"poll-item-id-multiple-choice\")\r", - "pm.globals.unset(\"poll-item-id-open-text\")\r", - "pm.globals.unset(\"poll-item-id-quiz\")\r", - "pm.globals.unset(\"poll-items\")\r", - "pm.globals.unset(\"poll-items-counter\")\r", - "pm.globals.unset(\"cookies\")\r", + "pm.globals.unset(\"poll-id\");\r", + "pm.globals.unset(\"poll-item-id\");\r", + "pm.globals.unset(\"poll-item-id-multiple-choice\");\r", + "pm.globals.unset(\"poll-item-id-open-text\");\r", + "pm.globals.unset(\"poll-item-id-quiz\");\r", + "pm.globals.unset(\"poll-items\");\r", + "pm.globals.unset(\"poll-items-counter\");\r", + "pm.globals.unset(\"cookies\");\r", + "pm.globals.unset(\"count-next-presentation-endpoint\");\r", "\r", "// Just for newman since we don't have our Livepoll Dev environment there\r", "pm.environment.set('base-url', 'http://localhost:8080');\r", @@ -813,7 +814,8 @@ "exec": [ "pm.test(\"Status code is 201\", function () {\r", " pm.response.to.have.status(201);\r", - "});" + "});\r", + "pm.globals.set(\"poll-id\", pm.response.json().id)" ], "type": "text/javascript" } @@ -859,8 +861,7 @@ "exec": [ "pm.test(\"Status code is 200\", function () {\r", " pm.response.to.have.status(200);\r", - "});\r", - "pm.globals.set(\"poll-id\", pm.response.json()[0].id)" + "});" ], "type": "text/javascript" } @@ -1153,11 +1154,27 @@ " pm.response.to.have.status(200);\r", "});\r", "\r", - "if(pm.response.text() == \"Poll over\"){\r", - " postman.setNextRequest(\"Get poll item\")\r", - "}else{\r", - " postman.setNextRequest(\"Next presentation item\")\r", - "}" + "\r", + "// Count\r", + "if (pm.globals.has('count-next-presentation-endpoint')) {\r", + " const newCount = pm.globals.get('count-next-presentation-endpoint') + 1;\r", + " pm.globals.set('count-next-presentation-endpoint', newCount);\r", + "} else {\r", + " // init with default value\r", + " pm.globals.set('count-next-presentation-endpoint', 0);\r", + "}\r", + "\r", + "\r", + "if (pm.response.json()['result'] == 'Poll over') {\r", + " postman.setNextRequest('Get poll item');\r", + "} else {\r", + " // Avoid infinite loop\r", + " if (pm.globals.get('count-next-presentation-endpoint') == 6) {\r", + " throw new Error('Stuck in infinite loop of \"Next presentation item\" endpoint');\r", + " }\r", + " postman.setNextRequest('Next presentation item');\r", + "}\r", + "" ], "type": "text/javascript" } diff --git a/src/main/kotlin/de/livepoll/api/controller/PollController.kt b/src/main/kotlin/de/livepoll/api/controller/PollController.kt index ddfb1d07..64e16bfb 100644 --- a/src/main/kotlin/de/livepoll/api/controller/PollController.kt +++ b/src/main/kotlin/de/livepoll/api/controller/PollController.kt @@ -28,9 +28,10 @@ class PollController( @PostMapping fun createPoll(@RequestBody newPoll: PollDtoIn, @AuthenticationPrincipal user: User): ResponseEntity<*> { val addedPoll = pollService.createPoll(newPoll, user.id) - return ResponseEntity.created(URI(newPoll.name)).body(addedPoll) + return ResponseEntity.created(URI(addedPoll.id.toString())).body(addedPoll) } + //--------------------------------------------- Get ---------------------------------------------------------------- @ApiOperation(value = "Get poll", tags = ["Poll"]) @@ -56,16 +57,20 @@ class PollController( @ApiOperation(value = "Get next poll item for presentation", tags = ["Poll presentation"]) @GetMapping("/{id}/next-item") - fun getNextPollItem(@PathVariable(name = "id") pollId: Long, @AuthenticationPrincipal user: User): ResponseEntity<*> { + fun getNextPollItem( + @PathVariable(name = "id") pollId: Long, + @AuthenticationPrincipal user: User + ): ResponseEntity<*> { accountService.checkAuthorizationByPollId(pollId) val item = pollService.getNextPollItem(pollId) - return if (item!=null){ + return if (item != null) { ResponseEntity.ok(item) - }else{ - ResponseEntity.ok("Poll over") + } else { + ResponseEntity.ok("{\"result\": \"Poll over\"}") } } + //-------------------------------------------- Update -------------------------------------------------------------- @ApiOperation(value = "Update slug", tags = ["Poll"]) @@ -75,6 +80,7 @@ class PollController( return pollService.updatePoll(pollId, updatedPoll) } + //-------------------------------------------- Delete -------------------------------------------------------------- @ApiOperation(value = "Delete poll", tags = ["Poll"]) @@ -83,4 +89,5 @@ class PollController( accountService.checkAuthorizationByPollId(pollId) return pollService.deletePoll(pollId) } + } diff --git a/src/main/kotlin/de/livepoll/api/controller/PollItemController.kt b/src/main/kotlin/de/livepoll/api/controller/PollItemController.kt index 422fdc03..6ff71493 100644 --- a/src/main/kotlin/de/livepoll/api/controller/PollItemController.kt +++ b/src/main/kotlin/de/livepoll/api/controller/PollItemController.kt @@ -42,6 +42,7 @@ class PollItemController( return ResponseEntity.created(URI(newItem.pollId.toString())).body(addedItem) } + //--------------------------------------------- Get ---------------------------------------------------------------- @ApiOperation(value = "Get poll item", tags = ["Poll item"]) @@ -51,29 +52,40 @@ class PollItemController( return pollItemService.getPollItem(pollItemId) } + //-------------------------------------------- Update -------------------------------------------------------------- @ApiOperation(value = "Update multiple choice item", tags = ["Poll item"]) @PutMapping("/multiple-choice/{pollItemId}") - fun updateMultipleChoiceItem(@RequestBody updatedItem: MultipleChoiceItemWithPositionDtoIn, @PathVariable(name="pollItemId") pollItemId: Long): ResponseEntity<*> { + fun updateMultipleChoiceItem( + @RequestBody updatedItem: MultipleChoiceItemWithPositionDtoIn, + @PathVariable(name = "pollItemId") pollItemId: Long + ): ResponseEntity<*> { accountService.checkAuthorizationByPollItemId(pollItemId) return ResponseEntity.ok(pollItemService.updateMultipleChoiceItem(pollItemId, updatedItem)) } @ApiOperation(value = "Update quiz item", tags = ["Poll item"]) @PutMapping("/quiz/{pollItemId}") - fun updateQuizItem(@RequestBody updatedItem: QuizItemWithPositionDtoIn, @PathVariable(name="pollItemId") pollItemId: Long): ResponseEntity<*> { + fun updateQuizItem( + @RequestBody updatedItem: QuizItemWithPositionDtoIn, + @PathVariable(name = "pollItemId") pollItemId: Long + ): ResponseEntity<*> { accountService.checkAuthorizationByPollItemId(pollItemId) return ResponseEntity.ok(pollItemService.updateQuizItem(pollItemId, updatedItem)) } @ApiOperation(value = "Update open text item", tags = ["Poll item"]) @PutMapping("/open-text/{pollItemId}") - fun updateOpenTextItem(@RequestBody updatedItem: OpenTextItemWithPositionDtoIn, @PathVariable(name="pollItemId") pollItemId: Long): ResponseEntity<*> { + fun updateOpenTextItem( + @RequestBody updatedItem: OpenTextItemWithPositionDtoIn, + @PathVariable(name = "pollItemId") pollItemId: Long + ): ResponseEntity<*> { accountService.checkAuthorizationByPollItemId(pollItemId) return ResponseEntity.ok(pollItemService.updateOpenTextItem(pollItemId, updatedItem)) } + //-------------------------------------------- Delete -------------------------------------------------------------- @ApiOperation(value = "Delete poll item", tags = ["Poll item"]) @@ -83,4 +95,5 @@ class PollItemController( pollItemService.deleteItem(pollItemId) return ResponseEntity.ok("Deleted poll item") } + } diff --git a/src/main/kotlin/de/livepoll/api/controller/WebSocketController.kt b/src/main/kotlin/de/livepoll/api/controller/WebSocketController.kt index b8a47217..6e25414e 100644 --- a/src/main/kotlin/de/livepoll/api/controller/WebSocketController.kt +++ b/src/main/kotlin/de/livepoll/api/controller/WebSocketController.kt @@ -10,9 +10,11 @@ import org.springframework.stereotype.Controller class WebSocketController( private val webSocketService: WebSocketService ) { + @MessageMapping("/{pollItemId}") fun processAnswer(@DestinationVariable pollItemId: Long, @Payload answer: String) { webSocketService.saveAnswer(pollItemId, answer) webSocketService.sendItemWithAnswers(pollItemId) } -} \ No newline at end of file + +} diff --git a/src/main/kotlin/de/livepoll/api/service/AccountService.kt b/src/main/kotlin/de/livepoll/api/service/AccountService.kt index 54b97299..2d77f88a 100644 --- a/src/main/kotlin/de/livepoll/api/service/AccountService.kt +++ b/src/main/kotlin/de/livepoll/api/service/AccountService.kt @@ -40,6 +40,11 @@ class AccountService( private val pollItemRepository: PollItemRepository ) { + /** + * Create a new account. + * + * @param user the new user object that should be saved in the database + */ fun createAccount(user: User): User { return user.apply { if (userRepository.existsByUsername(username) || userRepository.existsByEmail(email)) @@ -52,18 +57,34 @@ class AccountService( } } + /** + * Create a test user for postman. + */ fun createPostmanAccount() { if (userRepository.existsByUsername("postman")) return - val user = User(0, "postman", "noreply@live-poll.de", passwordEncoder.encode("1234"), - true, "ROLE_USER", emptyList()) + val user = User( + 0, "postman", "noreply@live-poll.de", passwordEncoder.encode("1234"), + true, "ROLE_USER", emptyList() + ) userRepository.saveAndFlush(user) } + /** + * Create a verification token for account confirmation and save it in the database. + * + * @param user the user for whom the token is generated + * @param token the token string + */ fun createVerificationToken(user: User, token: String) { val verificationToken = VerificationToken(0, token, user.username, calculateExpiryDate()) verificationTokenRepository.saveAndFlush(verificationToken) } + /** + * Confirm an account. + * + * @param token the token string to confirm the new account + */ fun confirmAccount(token: String): Boolean { val verificationToken = verificationTokenRepository.findByToken(token) if (verificationToken.expiryDate.after(Date())) { @@ -79,6 +100,12 @@ class AccountService( .apply { add(Calendar.MINUTE, 60 * 24) } .time + /** + * Create jwt token for login. This method does not check if the password is correct. This is done with the authentication manager before this method is called. + * + * @param username the name of the user you want to login with + * @return a ResponseEntity object is returned with an encrypted jwt token for future authentication in the header + */ fun login(username: String): ResponseEntity<*> { val user = userRepository.findByUsername(username) ?: userRepository.findByEmail(username) user?.run { @@ -101,6 +128,11 @@ class AccountService( throw UsernameNotFoundException("User not found") } + /** + * Logout + * + * @param request the http request that contains the jwt token that should be blocked + */ fun logout(request: HttpServletRequest): ResponseEntity<*> { SecurityContextHolder.getContext().authentication = null val accessTokenCookieName = System.getenv("LIVE_POLL_JWT_AUTH_COOKIE_NAME") @@ -123,6 +155,12 @@ class AccountService( throw ResponseStatusException(HttpStatus.FORBIDDEN, "You are not authorized") } + /** + * Check if user should have access to this poll. + * + * @param id the id of the poll + * @return a boolean that indicates whether access is permitted or not + */ fun checkAuthorizationByPollId(id: Long): Boolean { try { if (SecurityContextHolder.getContext().authentication.name == pollRepository.getOne(id).user.username) @@ -133,6 +171,12 @@ class AccountService( } } + /** + * Check if user should have access to this poll item. + * + * @param id the id of the poll item + * @return a boolean that indicates whether access is permitted or not + */ fun checkAuthorizationByPollItemId(id: Long): Boolean { try { if (SecurityContextHolder.getContext().authentication.name == pollItemRepository.getOne(id).poll.user.username) diff --git a/src/main/kotlin/de/livepoll/api/service/JwtUserDetailsService.kt b/src/main/kotlin/de/livepoll/api/service/JwtUserDetailsService.kt index 107e6cb3..d79e1823 100644 --- a/src/main/kotlin/de/livepoll/api/service/JwtUserDetailsService.kt +++ b/src/main/kotlin/de/livepoll/api/service/JwtUserDetailsService.kt @@ -21,4 +21,5 @@ class JwtUserDetailsService : UserDetailsService { } return user } + } diff --git a/src/main/kotlin/de/livepoll/api/service/PollItemService.kt b/src/main/kotlin/de/livepoll/api/service/PollItemService.kt index edceac81..ac4a7ce5 100644 --- a/src/main/kotlin/de/livepoll/api/service/PollItemService.kt +++ b/src/main/kotlin/de/livepoll/api/service/PollItemService.kt @@ -35,6 +35,12 @@ class PollItemService { //--------------------------------------------- Get ---------------------------------------------------------------- + /** + * Get a single poll item. + * + * @param pollItemId the id of the poll item + * @return the poll item in dto format + */ fun getPollItem(pollItemId: Long): PollItemDtoOut { pollItemRepository.findById(pollItemId) .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Poll item not found") } @@ -58,6 +64,11 @@ class PollItemService { } } + /** + * Delete a single poll item. + * + * @param itemId the id of the poll item which should be deleted + */ fun deleteItem(itemId: Long) { pollItemRepository.findById(itemId).orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Poll item not found") @@ -73,6 +84,12 @@ class PollItemService { //-------------------------------------------- Create -------------------------------------------------------------- + /** + * Create a multiple choice item. + * + * @param item the new multiple choice item in dto format + * @return the created multiplce choice item in dto format + */ fun createMultipleChoiceItem(item: MultipleChoiceItemDtoIn): MultipleChoiceItemDtoOut { pollRepository.findById(item.pollId) .orElseThrow { @@ -101,6 +118,12 @@ class PollItemService { } } + /** + * Create a quiz item. + * + * @param item the new quiz item in dto format + * @return the created quiz item in dto format + */ fun createQuizItem(item: QuizItemDtoIn): QuizItemDtoOut { pollRepository.findById(item.pollId) .orElseThrow { @@ -129,6 +152,12 @@ class PollItemService { } } + /** + * Create an open text item. + * + * @param item the new open text item in dto format + * @return the created open text item in dto format + */ fun createOpenTextItem(item: OpenTextItemDtoIn): OpenTextItemDtoOut { pollRepository.findById(item.pollId) .orElseThrow { @@ -157,6 +186,10 @@ class PollItemService { * * This method works in-place and will adjust the poll items list.
* Old and new position are counted from 1 onwards, NOT from 0! + * + * @param oldPos the old item position + * @param newPos the new item position + * @param pollItems a list of poll items */ fun movePollItem(oldPos: Int, newPos: Int, pollItems: MutableList) { // Check if new position is existent @@ -262,6 +295,13 @@ class PollItemService { item.answers.addAll(toAddAnswers as Collection) } + /** + * Update a multiple choice item. + * + * @param pollItemId the id of the item that should be updated + * @param pollItem the new item in dto format + * @return the updated item + */ fun updateMultipleChoiceItem( pollItemId: Long, pollItem: MultipleChoiceItemWithPositionDtoIn @@ -280,6 +320,13 @@ class PollItemService { } } + /** + * Update a quiz item. + * + * @param pollItemId the id of the item that should be updated + * @param pollItem the new item in dto format + * @return the updated item + */ fun updateQuizItem(pollItemId: Long, pollItem: QuizItemWithPositionDtoIn): QuizItemDtoOut { if (pollItem.selectionOptions.size < 2) { throw ResponseStatusException( @@ -305,6 +352,13 @@ class PollItemService { } } + /** + * Update an open text item. + * + * @param pollItemId the id of the item that should be updated + * @param pollItem the new item in dto format + * @return the updated item + */ fun updateOpenTextItem(pollItemId: Long, pollItem: OpenTextItemWithPositionDtoIn): OpenTextItemDtoOut { openTextItemRepository.findById(pollItemId) .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Poll item not found") } diff --git a/src/main/kotlin/de/livepoll/api/service/PollService.kt b/src/main/kotlin/de/livepoll/api/service/PollService.kt index d469c1fa..1a8109f2 100644 --- a/src/main/kotlin/de/livepoll/api/service/PollService.kt +++ b/src/main/kotlin/de/livepoll/api/service/PollService.kt @@ -15,7 +15,6 @@ import org.quartz.JobKey import org.quartz.TriggerKey import org.springframework.dao.EmptyResultDataAccessException import org.springframework.http.HttpStatus -import org.springframework.http.ResponseEntity import org.springframework.scheduling.quartz.SchedulerFactoryBean import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -25,82 +24,109 @@ import java.util.* @Service class PollService( - private val userRepository: UserRepository, - private val pollRepository: PollRepository, - private val pollItemService: PollItemService, - private val webSocketService: WebSocketService, - private val schedulerFactory: SchedulerFactoryBean, - private val jobScheduleCrator: JobScheduleCrator + private val userRepository: UserRepository, + private val pollRepository: PollRepository, + private val pollItemService: PollItemService, + private val webSocketService: WebSocketService, + private val schedulerFactory: SchedulerFactoryBean, + private val jobScheduleCrator: JobScheduleCrator ) { //--------------------------------------------- Get ---------------------------------------------------------------- + /** + * Get a poll. + * + * @param pollId the id of the poll + * @return poll in dto format + */ fun getPoll(pollId: Long): PollDtoOut { return pollRepository.findById(pollId) - .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Poll not found") } - .run { this.toDtoOut() } + .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Poll not found") } + .run { this.toDtoOut() } } + /** + * Get poll items for a specific poll. + * + * @param pollId the id of the poll + * @return a list of poll items in dto format + */ fun getPollItemsForPoll(pollId: Long): List { pollRepository.findById(pollId) - .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Poll not found") } - .run { - return this.pollItems.map { pollItemService.getPollItem(it.id) } - } + .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Poll not found") } + .run { + return this.pollItems.map { pollItemService.getPollItem(it.id) } + } } //-------------------------------------------- Create -------------------------------------------------------------- + /** + * Create a new poll. + * + * @param pollDto the new poll in dto format + * @param userId the user who creates the new poll + * @return the new poll in dto format + */ fun createPoll(pollDto: PollDtoIn, userId: Long): PollDtoOut { userRepository.findById(userId) - .orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "User not found") - } - .run { - val r = Random() - var slug: String - do { - slug = "" - for (i in 1..6) { - slug += r.nextInt(16) - } - } while (!isSlugUnique(slug)) - val poll = Poll( - 0, - this, - pollDto.name, - pollDto.startDate, - pollDto.endDate, - slug, - null, - emptyList().toMutableList() - ) - val pollFromDb = pollRepository.saveAndFlush(poll) - try { - if (pollFromDb.startDate != null && pollFromDb.endDate != null) { - schedulePoll(pollFromDb.id, pollFromDb.startDate!!, pollFromDb.endDate!!) - return pollFromDb.toDtoOut() - } else { - pollFromDb.startDate = null - pollFromDb.endDate = null - return pollRepository.saveAndFlush(pollFromDb).toDtoOut() - } - } catch (ex: ResponseStatusException) { + .orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "User not found") + } + .run { + // Generate random slug + val r = Random() + var slug: String + do { + slug = "" + for (i in 1..6) { + slug += r.nextInt(16) + } + } while (!isSlugUnique(slug)) + + val poll = Poll( + 0, + this, + pollDto.name, + pollDto.startDate, + pollDto.endDate, + slug, + null, + emptyList().toMutableList() + ) + val pollFromDb = pollRepository.saveAndFlush(poll) + + return try { + if (pollFromDb.startDate != null && pollFromDb.endDate != null) { + schedulePoll(pollFromDb.id, pollFromDb.startDate!!, pollFromDb.endDate!!) + pollFromDb.toDtoOut() + } else { pollFromDb.startDate = null pollFromDb.endDate = null - return pollRepository.saveAndFlush(pollFromDb).toDtoOut() + pollRepository.saveAndFlush(pollFromDb).toDtoOut() } + } catch (ex: ResponseStatusException) { + pollFromDb.startDate = null + pollFromDb.endDate = null + pollRepository.saveAndFlush(pollFromDb).toDtoOut() } + } } //-------------------------------------------- Delete -------------------------------------------------------------- - fun deletePoll(id: Long) { + /** + * Delete a single poll. All items belonging to the poll are also deleted. + * + * @param pollId + */ + fun deletePoll(pollId: Long) { try { - pollRepository.deleteById(id) + pollRepository.deleteById(pollId) } catch (ex: EmptyResultDataAccessException) { throw ResponseStatusException(HttpStatus.NO_CONTENT) } @@ -109,11 +135,20 @@ class PollService( //-------------------------------------------- Update -------------------------------------------------------------- + /** + * Update a poll. + * + * @param pollId the id of the poll which should be updated + * @param poll a poll in dto format that contains the new data + * @return the updated poll in dto format + */ fun updatePoll(pollId: Long, poll: PollDtoIn): PollDtoOut { pollRepository.findById(pollId).orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "This poll does not exist") }.run { this.name = poll.name + this.currentItem = poll.currentItem + if (poll.startDate != null && poll.endDate != null) { updateScheduledPoll(pollId, poll.startDate, poll.endDate) this.startDate = poll.startDate @@ -123,34 +158,51 @@ class PollService( this.startDate = null this.endDate = null } - if (poll.slug != null && isSlugUnique(poll.slug)) { - this.slug = poll.slug + + if (poll.slug != null && this.slug != poll.slug) { + if (isSlugUnique(poll.slug)) { + this.slug = poll.slug + } else { + pollRepository.saveAndFlush(this) + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Slug already exists") + } } - this.currentItem = poll.currentItem - webSocketService.sendCurrentItem(this.slug, this.id, this.currentItem) + + if (this.currentItem != null) { + webSocketService.sendCurrentItem(this.slug, this.id, this.currentItem) + webSocketService.sendItemWithAnswers(this.currentItem!!) + } + return pollRepository.saveAndFlush(this).toDtoOut() } } + /** + * Set the active item from the poll to the next following item. + * + * @param pollId the id of the poll where the active item is to be continued + * @return the next poll item in dto format + */ fun getNextPollItem(pollId: Long): PollItemDtoOut? { pollRepository.findById(pollId).orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "This poll does not exist") }.run { pollItems.sortBy { it.position } - if (currentItem == null) { - this.currentItem = pollItems[0].id - } else if (currentItem == pollItems.last().id) { - this.currentItem = null - } else { - val oldItem = pollItems.find { it.id == currentItem } - val newItem = pollItems.find { it.position == requireNotNull(oldItem).position + 1 } - this.currentItem = requireNotNull(newItem).id + when (currentItem) { + null -> this.currentItem = pollItems[0].id // set to first item + pollItems.last().id -> this.currentItem = null // end reached + else -> { + val oldItem = pollItems.find { it.id == currentItem } + val newItem = pollItems.find { it.position == requireNotNull(oldItem).position + 1 } + this.currentItem = requireNotNull(newItem).id + } } webSocketService.sendCurrentItem(this.slug, this.id, this.currentItem) pollRepository.saveAndFlush(this) + return if (this.currentItem != null) { pollItemService.getPollItem(this.currentItem!!) - }else{ + } else { null } } @@ -158,36 +210,71 @@ class PollService( fun isSlugUnique(slug: String) = pollRepository.findBySlug(slug) == null + /** + * Schedule a poll. + * + * @param pollId the id of the poll that should be scheduled + * @param startDate the start date of the poll + * @param stopDate the end date of the poll + */ private fun schedulePoll(pollId: Long, startDate: Date, stopDate: Date) { if (startDate.before(GregorianCalendar.getInstance().time) || stopDate.before(GregorianCalendar.getInstance().time)) { - throw ResponseStatusException(HttpStatus.CONFLICT, "Poll was not planned because start or end date is in the past") + throw ResponseStatusException( + HttpStatus.CONFLICT, + "Poll was not planned because start or end date is in the past" + ) } else { - val jobDetailStart = jobScheduleCrator.createJob(StartPollPresentationJob::class.java, "start-poll-" + pollId.toString(), pollId) - val triggerStart = jobScheduleCrator.createSimpleTrigger("start-poll-trigger-" + pollId.toString(), startDate) + val jobDetailStart = jobScheduleCrator.createJob( + StartPollPresentationJob::class.java, + "start-poll-$pollId", + pollId + ) + val triggerStart = + jobScheduleCrator.createSimpleTrigger("start-poll-trigger-$pollId", startDate) schedulerFactory.`object`!!.scheduleJob(jobDetailStart, triggerStart) - val jobDetailStop = jobScheduleCrator.createJob(StopPollPresentationJob::class.java, "stop-poll-" + pollId.toString(), pollId) - val triggerStop = jobScheduleCrator.createSimpleTrigger("stop-poll-trigger-" + pollId.toString(), stopDate) + val jobDetailStop = jobScheduleCrator.createJob( + StopPollPresentationJob::class.java, + "stop-poll-$pollId", + pollId + ) + val triggerStop = jobScheduleCrator.createSimpleTrigger("stop-poll-trigger-$pollId", stopDate) schedulerFactory.`object`!!.scheduleJob(jobDetailStop, triggerStop) } } + /** + * Update the start and end date of an poll which has already been planned. + * + * @param pollId the id of the poll that should be scheduled + * @param startDate the start date of the poll + * @param stopDate the end date of the poll + */ fun updateScheduledPoll(pollId: Long, startDate: Date, stopDate: Date) { if (startDate.before(GregorianCalendar.getInstance().time) || stopDate.before(GregorianCalendar.getInstance().time)) { - throw ResponseStatusException(HttpStatus.CONFLICT, "Poll was not planned because start or end date is in the past") + throw ResponseStatusException( + HttpStatus.CONFLICT, + "Poll was not planned because start or end date is in the past" + ) } else { - val jobNameStart: String = "start-poll-trigger-" + pollId - val jobNameStop: String = "stop-poll-trigger-" + pollId + val jobNameStart = "start-poll-trigger-$pollId" + val jobNameStop = "stop-poll-trigger-$pollId" val triggerStart = jobScheduleCrator.createSimpleTrigger(jobNameStart, startDate) val triggerStop = jobScheduleCrator.createSimpleTrigger(jobNameStop, stopDate) - val returnDate = schedulerFactory.`object`!!.rescheduleJob(TriggerKey.triggerKey(jobNameStart), triggerStart) + val returnDate = + schedulerFactory.`object`!!.rescheduleJob(TriggerKey.triggerKey(jobNameStart), triggerStart) schedulerFactory.`object`!!.rescheduleJob(TriggerKey.triggerKey(jobNameStop), triggerStop) - if(returnDate == null){ + if (returnDate == null) { schedulePoll(pollId, startDate, stopDate) } } } + /** + * Start a poll. + * + * @param pollId the id of the poll which should be started + */ @Transactional fun executeStartPoll(pollId: Long) { pollRepository.findById(pollId).ifPresent { @@ -197,6 +284,11 @@ class PollService( } } + /** + * Stop a poll. + * + * @param pollId the id of the poll which should be stoped + */ fun executeStopPoll(pollId: Long) { pollRepository.findById(pollId).ifPresent { it.currentItem = null @@ -205,9 +297,14 @@ class PollService( } } + /** + * Unschedule a poll. + * + * @param pollId the id the poll which should be unscheduled + */ fun stopScheduledPoll(pollId: Long) { - schedulerFactory.`object`!!.deleteJob(JobKey("start-poll-" + pollId)) - schedulerFactory.`object`!!.deleteJob(JobKey("stop-poll-" + pollId)) + schedulerFactory.`object`!!.deleteJob(JobKey("start-poll-$pollId")) + schedulerFactory.`object`!!.deleteJob(JobKey("stop-poll-$pollId")) } } diff --git a/src/main/kotlin/de/livepoll/api/service/WebSocketService.kt b/src/main/kotlin/de/livepoll/api/service/WebSocketService.kt index facd4476..da4b7639 100644 --- a/src/main/kotlin/de/livepoll/api/service/WebSocketService.kt +++ b/src/main/kotlin/de/livepoll/api/service/WebSocketService.kt @@ -32,6 +32,13 @@ class WebSocketService( ) { private val websocketPrefix = "/v1/websocket" + /** + * Send the current item from a poll to all participants. + * + * @param slug the url slug for the websocket url + * @param pollId the id of the poll to which the current item belongs + * @param currentItemId the id of the current item that should be send + */ fun sendCurrentItem(slug: String, pollId: Long, currentItemId: Long?) { val url = "${websocketPrefix}/poll/$slug" if (currentItemId != null) { @@ -50,6 +57,12 @@ class WebSocketService( } } + /** + * Save an answer which is sent from a participant. + * + * @param pollItemId the id of the poll item for which this answer is intended + * @param payload a JSON string that contains the answer item + */ fun saveAnswer(pollItemId: Long, payload: String) { val mapper = ObjectMapper() when (mapper.readValue(payload, Map::class.java)["type"].toString()) { @@ -57,7 +70,7 @@ class WebSocketService( PollItemType.MULTIPLE_CHOICE.representation -> { val obj: MultipleChoiceItemParticipantAnswerDtoIn = mapper.readValue(payload, MultipleChoiceItemParticipantAnswerDtoIn::class.java) - val multipleChoiceItemAnswer = multipleChoiceItemAnswerRepository.getOne(obj.id) + val multipleChoiceItemAnswer = multipleChoiceItemAnswerRepository.getById(obj.id) multipleChoiceItemAnswer.answerCount++ multipleChoiceItemAnswerRepository.saveAndFlush(multipleChoiceItemAnswer) } @@ -66,7 +79,7 @@ class WebSocketService( PollItemType.OPEN_TEXT.representation -> { val obj: OpenTextItemParticipantAnswerDtoIn = mapper.readValue(payload, OpenTextItemParticipantAnswerDtoIn::class.java) - val pollItem = openTextItemRepository.getOne(pollItemId) + val pollItem = openTextItemRepository.getById(pollItemId) openTextItemAnswerRepository.saveAndFlush(obj.toDbEntity(pollItem)) } @@ -74,15 +87,20 @@ class WebSocketService( PollItemType.QUIZ.representation -> { val obj: QuizItemParticipantAnswerDtoIn = mapper.readValue(payload, QuizItemParticipantAnswerDtoIn::class.java) - val quizItemAnswer = quizItemAnswerRepository.getOne(obj.id) + val quizItemAnswer = quizItemAnswerRepository.getById(obj.id) quizItemAnswer.answerCount++ quizItemAnswerRepository.saveAndFlush(quizItemAnswer) } } } + /** + * Send an item along with its answers to the presenter. + * + * @param itemId the id of the item that should be sent to the belonging poll presentation endpoint + */ @Transactional - fun sendItemWithAnswers(itemId: Long){ + fun sendItemWithAnswers(itemId: Long) { val item: PollItemDtoOut = pollItemService.getPollItem(itemId) val url = "$websocketPrefix/presentation/${item.pollId}" simpUserRegistry.users.forEach { diff --git a/src/main/kotlin/de/livepoll/api/util/websocket/SubscribeListener.kt b/src/main/kotlin/de/livepoll/api/util/websocket/SubscribeListener.kt index 85203ac7..479829cc 100644 --- a/src/main/kotlin/de/livepoll/api/util/websocket/SubscribeListener.kt +++ b/src/main/kotlin/de/livepoll/api/util/websocket/SubscribeListener.kt @@ -2,6 +2,7 @@ package de.livepoll.api.util.websocket import de.livepoll.api.repository.PollRepository import de.livepoll.api.service.PollItemService +import de.livepoll.api.service.WebSocketService import org.springframework.context.ApplicationListener import org.springframework.http.HttpStatus import org.springframework.messaging.simp.SimpMessageSendingOperations @@ -12,27 +13,49 @@ import org.springframework.web.socket.messaging.SessionSubscribeEvent @Component class SubscribeListener( - private val messagingTemplate: SimpMessageSendingOperations, - private val pollRepository: PollRepository, - private val pollItemService: PollItemService + private val messagingTemplate: SimpMessageSendingOperations, + private val pollRepository: PollRepository, + private val pollItemService: PollItemService, + private val webSocketService: WebSocketService ) : ApplicationListener { @Transactional override fun onApplicationEvent(event: SessionSubscribeEvent) { - if( event.message.headers["simpDestination"].toString().contains("poll")){ - val slug = event.message.headers["simpDestination"].toString().split("/").last() + val destination = event.message.headers["simpDestination"].toString() + if (destination.contains("poll")) { + val slug = destination.split("/").last() val poll = pollRepository.findBySlug(slug) + val url = "/v1/websocket/poll/$slug" if (poll == null) { + sendErrorMessage(event.user!!.name, url, "Error in the participant endpoint") throw ResponseStatusException(HttpStatus.NOT_FOUND) } else { - val url = "/v1/websocket/poll/$slug" if (poll.currentItem == null) { messagingTemplate.convertAndSendToUser(event.user!!.name, url, "{\"pollId\":${poll.id}}") + throw ResponseStatusException(HttpStatus.NOT_FOUND) } else { val pollItemDto = pollItemService.getPollItem(poll.currentItem!!) messagingTemplate.convertAndSendToUser(event.user!!.name, url, pollItemDto) } } + } else if (destination.contains("presentation")) { + val pollId = destination.split("/").last().toLong() + val url = "/v1/websocket/presentation/${pollId}" + pollRepository.findById(pollId).orElseThrow { + sendErrorMessage(event.user!!.name, url, "Error in the presentation endpoint") + throw ResponseStatusException(HttpStatus.NOT_FOUND) + }.run { + if (this.currentItem != null) { + webSocketService.sendItemWithAnswers(this.currentItem!!) + } else { + sendErrorMessage(event.user!!.name, url, "Error in the presentation endpoint") + throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + } } } + + private fun sendErrorMessage(username: String, url: String, errorMessage: String) { + messagingTemplate.convertAndSendToUser(username, url, "{\"error\":\"$errorMessage\"}") + } } From 18ebcb3e290c1e02047c26d57aa797bf2f052d3e Mon Sep 17 00:00:00 2001 From: Marc Auberer Date: Mon, 7 Jun 2021 16:45:12 +0200 Subject: [PATCH 25/25] Bump version to 0.7.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index ac9dbc1f..8bb73542 100644 --- a/pom.xml +++ b/pom.xml @@ -10,7 +10,7 @@ de.live-poll api - 0.6.0 + 0.7.0 Live-Poll API Backend for Live-Poll