diff --git a/.travis.yml b/.travis.yml index e72c4d798..77d26ec3a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -17,7 +17,7 @@ before_install: install: - git clone https://github.com/FAForever/faf-stack.git faf-stack && pushd faf-stack - && git checkout 1a37706 + && git checkout 6bdb8b3 && cp -r config.template config && popd - docker-compose -f faf-stack/docker-compose.yml up -d faf-db diff --git a/src/inttest/java/com/faforever/api/data/VotingElideTest.java b/src/inttest/java/com/faforever/api/data/VotingElideTest.java new file mode 100644 index 000000000..0e62612ef --- /dev/null +++ b/src/inttest/java/com/faforever/api/data/VotingElideTest.java @@ -0,0 +1,225 @@ +package com.faforever.api.data; + +import com.faforever.api.AbstractIntegrationTest; +import com.faforever.api.data.domain.VotingChoice; +import com.faforever.api.data.domain.VotingQuestion; +import com.faforever.api.security.OAuthScope; +import com.faforever.api.voting.VotingQuestionRepository; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.Sql.ExecutionPhase; + +import java.util.List; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Sql(executionPhase = ExecutionPhase.BEFORE_TEST_METHOD, scripts = "classpath:sql/prepDefaultUser.sql") +@Sql(executionPhase = ExecutionPhase.BEFORE_TEST_METHOD, scripts = "classpath:sql/prepVotingData.sql") +@Sql(executionPhase = ExecutionPhase.AFTER_TEST_METHOD, scripts = "classpath:sql/cleanVotingData.sql") +public class VotingElideTest extends AbstractIntegrationTest { + /* + { + "data": { + "type": "votingSubject", + "id": "2", + "attributes": { + "revealWinner": true + } + } + } + */ + private static final String PATCH_VOTING_SUBJECT_REVEAL_ID_2 = "{\n" + + " \"data\": {\n" + + " \"type\": \"votingSubject\",\n" + + " \"id\": \"2\",\n" + + " \"attributes\": {\n" + + " \"revealWinner\": true\n" + + " }\n" + + " }\n" + + "}"; + + /* + { + "data": { + "type": "votingSubject", + "id": "1", + "attributes": { + "revealWinner": true + } + } + } + */ + private static final String PATCH_VOTING_SUBJECT_REVEAL_ID_1 = "{\n" + + " \"data\": {\n" + + " \"type\": \"votingSubject\",\n" + + " \"id\": \"1\",\n" + + " \"attributes\": {\n" + + " \"revealWinner\": true\n" + + " }\n" + + " }\n" + + "}"; + /* + { + + "votingSubject":{ + "id":1 + }, + "votingAnswers":[ + { + "votingQuestion":{ + "id":1 + }, + "alternativeOrdinal":0, + "votingChoice":{ + "id":1 + } + } + ] + +} + */ + private static final String POST_VOTE_SUBJECT1 = + "{\n" + + "\n" + + "\"votingSubject\":{\n" + + "\"id\":1\n" + + "},\n" + + "\"votingAnswers\":[\n" + + "{\n" + + "\"votingQuestion\":{\n" + + "\"id\":1\n" + + "},\n" + + "\"alternativeOrdinal\":0,\n" + + "\"votingChoice\":{\n" + + "\"id\":1\n" + + "}\n" + + "}\n" + + "]\n" + + "\n" + + "}"; + + /* + { + + "votingSubject":{ + "id":2 + }, + "votingAnswers":[ + { + "votingQuestion":{ + "id":2 + }, + "alternativeOrdinal":0, + "votingChoice":{ + "id":3 + } + } + ] + + } + */ + private static final String POST_VOTE_SUBJECT2 = "{\n" + + "\n" + + " \t\"votingSubject\":{\n" + + " \t\t\"id\":2\n" + + " \t},\n" + + " \t\"votingAnswers\":[\n" + + " \t\t{\n" + + " \t\t\t\"votingQuestion\":{\n" + + " \t\t\t\t\"id\":2\n" + + " \t\t\t},\n" + + " \t\t\t\"alternativeOrdinal\":0,\n" + + " \t\t\t\"votingChoice\":{\n" + + " \t\t\t\t\"id\":3\n" + + " \t\t\t}\n" + + " \t\t}\n" + + " \t]\n" + + "\n" + + "}"; + + @Autowired + VotingQuestionRepository votingQuestionRepository; + + + @Test + @WithUserDetails(AUTH_MODERATOR) + public void noBodyCanSeeOtherPeoplesVote() throws Exception { + mockMvc.perform(get("/data/vote")) + .andExpect(status().isOk()) + .andExpect(content().string("{\"data\":[]}")); + } + + @Test + @WithUserDetails(AUTH_USER) + public void everyBodySeesVotingSubjects() throws Exception { + mockMvc.perform(get("/data/votingSubject")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(2))); + } + + @Test + @WithUserDetails(AUTH_USER) + public void everyBodySeesVotingQuestions() throws Exception { + mockMvc.perform(get("/data/votingQuestion")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(2))); + } + + @Test + @WithUserDetails(AUTH_USER) + public void everyBodySeesVotingChoices() throws Exception { + mockMvc.perform(get("/data/votingChoice")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(3))); + } + + @Test + @WithUserDetails(AUTH_MODERATOR) + public void testRevealWinnerOnEndedSubjectWorks() throws Exception { + mockMvc.perform(patch("/data/votingSubject/2").content(PATCH_VOTING_SUBJECT_REVEAL_ID_2)) + .andExpect(status().isNoContent()); + VotingQuestion question = votingQuestionRepository.getOne(2); + List winners = question.getWinners(); + assertThat(winners, hasSize(1)); + assertThat(winners.get(0).getId(), is(3)); + } + + @Test + @WithUserDetails(AUTH_MODERATOR) + public void testRevealWinnerOnNoneEndedSubjectFails() throws Exception { + mockMvc.perform(patch("/data/votingSubject/1").content(PATCH_VOTING_SUBJECT_REVEAL_ID_1)) + .andExpect(status().is(500)); + } + + @Test + @WithUserDetails(AUTH_MODERATOR) + public void postVote() throws Exception { + mockMvc.perform(post("/voting/vote").contentType(MediaType.APPLICATION_JSON).content(POST_VOTE_SUBJECT1).with(getOAuthToken(OAuthScope._VOTE))) + .andExpect(status().isOk()); + } + + @Test + @WithUserDetails(AUTH_USER) + public void postVoteWhereVoteAlreadyExists() throws Exception { + mockMvc.perform(post("/voting/vote").contentType(MediaType.APPLICATION_JSON).content(POST_VOTE_SUBJECT1).with(getOAuthToken(OAuthScope._VOTE))) + .andExpect(status().is(422)); + } + + @Test + @WithUserDetails(AUTH_USER) + public void postVoteOnEndedSubject() throws Exception { + mockMvc.perform(post("/voting/vote").contentType(MediaType.APPLICATION_JSON).content(POST_VOTE_SUBJECT2).with(getOAuthToken(OAuthScope._VOTE))) + .andExpect(status().is(422)); + } +} diff --git a/src/inttest/resources/sql/cleanVotingData.sql b/src/inttest/resources/sql/cleanVotingData.sql new file mode 100644 index 000000000..33b8249e1 --- /dev/null +++ b/src/inttest/resources/sql/cleanVotingData.sql @@ -0,0 +1,6 @@ +DELETE FROM winner_for_voting_question; +DELETE FROM voting_answer; +DELETE FROM vote; +DELETE FROM voting_choice; +DELETE FROM voting_question; +DELETE FROM voting_subject; diff --git a/src/inttest/resources/sql/prepDefaultUser.sql b/src/inttest/resources/sql/prepDefaultUser.sql index 60131e0aa..28d8e9c19 100644 --- a/src/inttest/resources/sql/prepDefaultUser.sql +++ b/src/inttest/resources/sql/prepDefaultUser.sql @@ -1,3 +1,6 @@ +DELETE FROM winner_for_voting_question; +DELETE FROM voting_answer; +DELETE FROM vote; DELETE FROM unique_id_users; DELETE FROM uniqueid; DELETE FROM global_rating; @@ -36,12 +39,12 @@ DELETE FROM login; INSERT INTO oauth_clients (id, name, client_secret, client_type, redirect_uris, default_redirect_uri, default_scope) VALUES ('test', 'test', 'test', 'public', 'http://localhost https://www.getpostman.com/oauth2/callback ', 'http://localhost', - 'read_events read_achievements upload_map upload_mod upload_avatar write_account_data'); + 'read_events read_achievements upload_map upload_mod upload_avatar write_account_data vote'); INSERT INTO login (id, login, email, password, steamid) VALUES (1, 'USER', 'user@faforever.com', '92b7b421992ef490f3b75898ec0e511f1a5c02422819d89719b20362b023ee4f', NULL), - (2, 'MODERATOR', 'moderator@faforever.com', '778ac5b81fa251b450f827846378739caee510c31b01cfa9d31822b88bed8441', 1234), - (3, 'ADMIN', 'admin@faforever.com', '835d6dc88b708bc646d6db82c853ef4182fabbd4a8de59c213f2b5ab3ae7d9be', NULL); + (2, 'MODERATOR', 'moderator@faforever.com', '778ac5b81fa251b450f827846378739caee510c31b01cfa9d31822b88bed8441', 1234), + (3, 'ADMIN', 'admin@faforever.com', '835d6dc88b708bc646d6db82c853ef4182fabbd4a8de59c213f2b5ab3ae7d9be', NULL); INSERT INTO lobby_admin (user_id, `group`) VALUES (2, 1), diff --git a/src/inttest/resources/sql/prepVotingData.sql b/src/inttest/resources/sql/prepVotingData.sql new file mode 100644 index 000000000..953ce1aaf --- /dev/null +++ b/src/inttest/resources/sql/prepVotingData.sql @@ -0,0 +1,40 @@ +DELETE FROM winner_for_voting_question; +DELETE FROM voting_answer; +DELETE FROM vote; +DELETE FROM voting_choice; +DELETE FROM voting_question; +DELETE FROM voting_subject; + +INSERT INTO voting_subject (id, subject_key, begin_of_vote_time, end_of_vote_time, min_games_to_vote, description_key, topic_url) +VALUES + (1, 'subject', NOW(), DATE_ADD(NOW(), INTERVAL 1 YEAR), 0, 'des', 'www.google.de'); + +INSERT INTO voting_subject (id, subject_key, reveal_winner, begin_of_vote_time, end_of_vote_time, min_games_to_vote, description_key, topic_url) +VALUES + (2, 'subject', 0, DATE_SUB(NOW(), INTERVAL 1 YEAR), DATE_SUB(NOW(), INTERVAL 45 DAY), 0, 'des', 'www.google.de'); + +INSERT INTO voting_question (id, max_answers, question_key, voting_subject_id, description_key, ordinal, alternative_voting) +VALUES + (1, 2, 'question', 1, 'des', 1, 1); + +INSERT INTO voting_choice (id, choice_text_key, voting_question_id, description_key, ordinal) VALUES + (1, 'text', 1, 'des', 1); + +INSERT INTO voting_choice (id, choice_text_key, voting_question_id, description_key, ordinal) VALUES + (2, 'text', 1, 'des', 2); + +INSERT INTO vote (id, player_id, voting_subject_id) VALUES + (1, 1, 1); + +INSERT INTO voting_question (id, max_answers, question_key, voting_subject_id, description_key, ordinal, alternative_voting) +VALUES + (2, 2, 'question', 2, 'des', 1, 1); + +INSERT INTO voting_choice (id, choice_text_key, voting_question_id, description_key, ordinal) VALUES + (3, 'text', 2, 'des', 2); + +INSERT INTO vote (id, player_id, voting_subject_id) VALUES + (2, 3, 2); + +INSERT INTO voting_answer (id, vote_id, voting_choice_id, alternative_ordinal) VALUES + (1, 2, 3, 0); diff --git a/src/main/java/com/faforever/api/config/swagger/SwaggerConfig.java b/src/main/java/com/faforever/api/config/swagger/SwaggerConfig.java index 930d931bd..1b85572b1 100644 --- a/src/main/java/com/faforever/api/config/swagger/SwaggerConfig.java +++ b/src/main/java/com/faforever/api/config/swagger/SwaggerConfig.java @@ -60,6 +60,7 @@ private Predicate paths() { regex("/users/.*"), regex("/mods/.*"), regex("/maps/.*"), - regex("/leaderboards/.*")); + regex("/leaderboards/.*"), + regex("/voting/.*")); } } diff --git a/src/main/java/com/faforever/api/data/domain/Vote.java b/src/main/java/com/faforever/api/data/domain/Vote.java new file mode 100644 index 000000000..0bd71fc3a --- /dev/null +++ b/src/main/java/com/faforever/api/data/domain/Vote.java @@ -0,0 +1,54 @@ +package com.faforever.api.data.domain; + +import com.faforever.api.data.checks.IsEntityOwner; +import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.annotation.ReadPermission; +import com.yahoo.elide.annotation.UpdatePermission; +import lombok.Setter; + +import javax.persistence.CascadeType; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.OneToMany; +import javax.persistence.Table; +import javax.persistence.Transient; +import java.util.Set; + +@Entity +@Table(name = "vote") +@Include(type = Vote.TYPE_NAME, rootLevel = true) +@ReadPermission(expression = IsEntityOwner.EXPRESSION) +@UpdatePermission(expression = "Prefab.Role.None") +@Setter +public class Vote extends AbstractEntity implements OwnableEntity { + public static final String TYPE_NAME = "vote"; + + private Player player; + private VotingSubject votingSubject; + private Set votingAnswers; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "player_id") + public Player getPlayer() { + return player; + } + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "voting_subject_id") + public VotingSubject getVotingSubject() { + return votingSubject; + } + + @OneToMany(mappedBy = "vote", cascade = CascadeType.ALL, orphanRemoval = true) + public Set getVotingAnswers() { + return votingAnswers; + } + + @Transient + @Override + public Login getEntityOwner() { + return getPlayer(); + } +} diff --git a/src/main/java/com/faforever/api/data/domain/VotingAnswer.java b/src/main/java/com/faforever/api/data/domain/VotingAnswer.java new file mode 100644 index 000000000..40e18768b --- /dev/null +++ b/src/main/java/com/faforever/api/data/domain/VotingAnswer.java @@ -0,0 +1,52 @@ +package com.faforever.api.data.domain; + +import com.faforever.api.data.checks.IsEntityOwner; +import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.annotation.ReadPermission; +import com.yahoo.elide.annotation.UpdatePermission; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.Table; +import javax.persistence.Transient; + +@Entity +@Table(name = "voting_answer") +@Include(type = VotingAnswer.TYPE_NAME) +@ReadPermission(expression = IsEntityOwner.EXPRESSION) +@UpdatePermission(expression = "Prefab.Role.None") +@Setter +public class VotingAnswer extends AbstractEntity implements OwnableEntity { + public static final String TYPE_NAME = "votingAnswer"; + + private Vote vote; + private Integer alternativeOrdinal; + private VotingChoice votingChoice; + + @Column(name = "alternative_ordinal") + public Integer getAlternativeOrdinal() { + return alternativeOrdinal; + } + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "vote_id") + public Vote getVote() { + return vote; + } + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "voting_choice_id") + public VotingChoice getVotingChoice() { + return votingChoice; + } + + @Transient + @Override + public Login getEntityOwner() { + return vote.getEntityOwner(); + } +} diff --git a/src/main/java/com/faforever/api/data/domain/VotingChoice.java b/src/main/java/com/faforever/api/data/domain/VotingChoice.java new file mode 100644 index 000000000..bd8c878a4 --- /dev/null +++ b/src/main/java/com/faforever/api/data/domain/VotingChoice.java @@ -0,0 +1,104 @@ +package com.faforever.api.data.domain; + +import com.faforever.api.data.checks.permission.IsModerator; +import com.faforever.api.data.listeners.VotingChoiceEnricher; +import com.fasterxml.jackson.annotation.JsonBackReference; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.yahoo.elide.annotation.Audit; +import com.yahoo.elide.annotation.Audit.Action; +import com.yahoo.elide.annotation.ComputedAttribute; +import com.yahoo.elide.annotation.CreatePermission; +import com.yahoo.elide.annotation.DeletePermission; +import com.yahoo.elide.annotation.Exclude; +import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.annotation.ReadPermission; +import com.yahoo.elide.annotation.SharePermission; +import com.yahoo.elide.annotation.UpdatePermission; +import lombok.Setter; + +import javax.persistence.CascadeType; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EntityListeners; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.OneToMany; +import javax.persistence.Table; +import javax.persistence.Transient; +import javax.validation.constraints.NotNull; +import java.util.Set; + +@Entity +@Table(name = "voting_choice") +@ReadPermission(expression = "Prefab.Role.All") +@SharePermission(expression = IsModerator.EXPRESSION) +@DeletePermission(expression = IsModerator.EXPRESSION) +@UpdatePermission(expression = IsModerator.EXPRESSION) +@CreatePermission(expression = IsModerator.EXPRESSION) +@Audit(action = Action.CREATE, logStatement = "Created voting choice with id: {0} ", logExpressions = {"${votingChoice.id}"}) +@Audit(action = Action.DELETE, logStatement = "Deleted voting choice with id: {0} ", logExpressions = {"${votingChoice.id}"}) +@Audit(action = Action.UPDATE, logStatement = "Updated voting choice with id: {0} ", logExpressions = {"${votingChoice.id}"}) +@Include(rootLevel = true, type = VotingChoice.TYPE_NAME) +@Setter +@EntityListeners(VotingChoiceEnricher.class) +public class VotingChoice extends AbstractEntity { + public static final String TYPE_NAME = "votingChoice"; + + private String choiceTextKey; + private String choiceText; + private String descriptionKey; + private String description; + private int numberOfAnswers; + private int ordinal; + private VotingQuestion votingQuestion; + private Set votingAnswers; + + @Column(name = "choice_text_key") + @NotNull + public String getChoiceTextKey() { + return choiceTextKey; + } + + @ComputedAttribute + @Transient + public String getChoiceText() { + return choiceText; + } + + @Column(name = "description_key") + public String getDescriptionKey() { + return descriptionKey; + } + + @ComputedAttribute + @Transient + public String getDescription() { + return description; + } + + @Column(name = "ordinal") + @NotNull + public int getOrdinal() { + return ordinal; + } + + @Transient + @ComputedAttribute + public int getNumberOfAnswers() { + return numberOfAnswers; + } + + @JsonBackReference + @JoinColumn(name = "voting_question_id") + @ManyToOne() + public VotingQuestion getVotingQuestion() { + return votingQuestion; + } + + @JsonIgnore + @Exclude + @OneToMany(mappedBy = "votingChoice", cascade = CascadeType.ALL, orphanRemoval = true) + public Set getVotingAnswers() { + return votingAnswers; + } +} diff --git a/src/main/java/com/faforever/api/data/domain/VotingQuestion.java b/src/main/java/com/faforever/api/data/domain/VotingQuestion.java new file mode 100644 index 000000000..d6b44623c --- /dev/null +++ b/src/main/java/com/faforever/api/data/domain/VotingQuestion.java @@ -0,0 +1,129 @@ +package com.faforever.api.data.domain; + +import com.faforever.api.data.checks.permission.IsModerator; +import com.faforever.api.data.listeners.VotingQuestionEnricher; +import com.fasterxml.jackson.annotation.JsonBackReference; +import com.fasterxml.jackson.annotation.JsonManagedReference; +import com.yahoo.elide.annotation.Audit; +import com.yahoo.elide.annotation.Audit.Action; +import com.yahoo.elide.annotation.ComputedAttribute; +import com.yahoo.elide.annotation.CreatePermission; +import com.yahoo.elide.annotation.DeletePermission; +import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.annotation.ReadPermission; +import com.yahoo.elide.annotation.SharePermission; +import com.yahoo.elide.annotation.UpdatePermission; +import lombok.EqualsAndHashCode; +import lombok.Setter; + +import javax.persistence.CascadeType; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EntityListeners; +import javax.persistence.FetchType; +import javax.persistence.JoinColumn; +import javax.persistence.JoinTable; +import javax.persistence.ManyToMany; +import javax.persistence.ManyToOne; +import javax.persistence.OneToMany; +import javax.persistence.Table; +import javax.persistence.Transient; +import javax.validation.constraints.NotNull; +import java.util.List; +import java.util.Set; + +@EqualsAndHashCode(of = "id", callSuper = false) +@Entity +@Table(name = "voting_question") +@ReadPermission(expression = "Prefab.Role.All") +@SharePermission(expression = IsModerator.EXPRESSION) +@DeletePermission(expression = IsModerator.EXPRESSION) +@UpdatePermission(expression = IsModerator.EXPRESSION) +@CreatePermission(expression = IsModerator.EXPRESSION) +@Audit(action = Action.CREATE, logStatement = "Created voting question with id:{0}", logExpressions = {"${votingQuestion.id}"}) +@Audit(action = Action.DELETE, logStatement = "Deleted voting question with id:{0}", logExpressions = {"${votingQuestion.id}"}) +@Audit(action = Action.UPDATE, logStatement = "Updated voting question with id:{0}", logExpressions = {"${votingQuestion.id}"}) +@Include(rootLevel = true, type = VotingQuestion.TYPE_NAME) +@Setter +@EntityListeners(VotingQuestionEnricher.class) +public class VotingQuestion extends AbstractEntity { + public static final String TYPE_NAME = "votingQuestion"; + + private int numberOfAnswers; + private String question; + private String description; + private String questionKey; + private String descriptionKey; + private int maxAnswers; + private Boolean alternativeQuestion; + private VotingSubject votingSubject; + private List winners; + private Set votingChoices; + + @UpdatePermission(expression = "Prefab.Common.UpdateOnCreate") + @Column(name = "alternative_voting") + public Boolean isAlternativeQuestion() { + return alternativeQuestion; + } + + /** + * Is evaluated when voting ended and revealVote is set to true + */ + @UpdatePermission(expression = "Prefab.Role.None") + @JoinTable(name = "winner_for_voting_question", + joinColumns = {@JoinColumn(name = "voting_question_id")}, + inverseJoinColumns = {@JoinColumn(name = "voting_choice_id")} + ) + @ManyToMany + public List getWinners() { + return winners; + } + + @ComputedAttribute + @Transient + public String getQuestion() { + return question; + } + + @ComputedAttribute + @Transient + public String getDescription() { + return description; + } + + @JsonBackReference + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "voting_subject_id") + public VotingSubject getVotingSubject() { + return votingSubject; + } + + @NotNull + @Column(name = "question_key", nullable = false) + public String getQuestionKey() { + return questionKey; + } + + @Column(name = "description_key") + public String getDescriptionKey() { + return descriptionKey; + } + + @UpdatePermission(expression = "Prefab.Common.UpdateOnCreate") + @Column(name = "max_answers") + public int getMaxAnswers() { + return maxAnswers; + } + + @Transient + @ComputedAttribute + public int getNumberOfAnswers() { + return numberOfAnswers; + } + + @JsonManagedReference + @OneToMany(mappedBy = "votingQuestion", cascade = CascadeType.ALL, orphanRemoval = true) + public Set getVotingChoices() { + return votingChoices; + } +} diff --git a/src/main/java/com/faforever/api/data/domain/VotingSubject.java b/src/main/java/com/faforever/api/data/domain/VotingSubject.java new file mode 100644 index 000000000..2d23121f1 --- /dev/null +++ b/src/main/java/com/faforever/api/data/domain/VotingSubject.java @@ -0,0 +1,131 @@ +package com.faforever.api.data.domain; + +import com.faforever.api.data.checks.permission.IsModerator; +import com.faforever.api.data.listeners.VotingSubjectEnricher; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonManagedReference; +import com.yahoo.elide.annotation.Audit; +import com.yahoo.elide.annotation.Audit.Action; +import com.yahoo.elide.annotation.ComputedAttribute; +import com.yahoo.elide.annotation.CreatePermission; +import com.yahoo.elide.annotation.DeletePermission; +import com.yahoo.elide.annotation.Exclude; +import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.annotation.ReadPermission; +import com.yahoo.elide.annotation.SharePermission; +import com.yahoo.elide.annotation.UpdatePermission; +import lombok.Setter; + +import javax.persistence.CascadeType; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EntityListeners; +import javax.persistence.OneToMany; +import javax.persistence.Table; +import javax.persistence.Transient; +import javax.validation.constraints.DecimalMin; +import javax.validation.constraints.NotNull; +import java.time.OffsetDateTime; +import java.util.Set; + +@Entity +@Table(name = "voting_subject") +@Include(rootLevel = true, type = VotingSubject.TYPE_NAME) +@ReadPermission(expression = "Prefab.Role.All") +@SharePermission(expression = IsModerator.EXPRESSION) +@DeletePermission(expression = IsModerator.EXPRESSION) +@UpdatePermission(expression = IsModerator.EXPRESSION) +@CreatePermission(expression = IsModerator.EXPRESSION) +@Audit(action = Action.CREATE, logStatement = "Created voting subject with id: {0}", logExpressions = {"${votingSubject.id}"}) +@Audit(action = Action.DELETE, logStatement = "Deleted voting subject with id: {0}", logExpressions = {"${votingSubject.id}"}) +@Audit(action = Action.UPDATE, logStatement = "Updated voting subject with id: {0}", logExpressions = {"${votingSubject.id}"}) +@Setter +@EntityListeners(VotingSubjectEnricher.class) +public class VotingSubject extends AbstractEntity { + public static final String TYPE_NAME = "votingSubject"; + + private String subjectKey; + private String subject; + private int numberOfVotes; + private String topicUrl; + private OffsetDateTime beginOfVoteTime; + private OffsetDateTime endOfVoteTime; + private int minGamesToVote; + private String descriptionKey; + private String description; + private Boolean revealWinner; + private Set votes; + private Set votingQuestions; + + @Column(name = "subject_key") + @NotNull + public String getSubjectKey() { + return subjectKey; + } + + @ComputedAttribute + @Transient + public String getSubject() { + return subject; + } + + @Column(name = "description_key") + public String getDescriptionKey() { + return descriptionKey; + } + + @Transient + @ComputedAttribute + public int getNumberOfVotes() { + return numberOfVotes; + } + + @UpdatePermission(expression = "Prefab.Common.UpdateOnCreate") + @NotNull + @Column(name = "begin_of_vote_time") + public OffsetDateTime getBeginOfVoteTime() { + return beginOfVoteTime; + } + + @UpdatePermission(expression = "Prefab.Common.UpdateOnCreate") + @NotNull + @Column(name = "end_of_vote_time") + public OffsetDateTime getEndOfVoteTime() { + return endOfVoteTime; + } + + @DecimalMin("0") + @Column(name = "min_games_to_vote") + public int getMinGamesToVote() { + return minGamesToVote; + } + + @ComputedAttribute + @Transient + public String getDescription() { + return description; + } + + @Column(name = "topic_url") + public String getTopicUrl() { + return topicUrl; + } + + @Column(name = "reveal_winner") + public Boolean getRevealWinner() { + return revealWinner; + } + + @JsonIgnore + @Exclude + @OneToMany(mappedBy = "votingSubject", cascade = CascadeType.ALL, orphanRemoval = true) + public Set getVotes() { + return votes; + } + + @JsonManagedReference + @OneToMany(mappedBy = "votingSubject", cascade = CascadeType.ALL, orphanRemoval = true) + public Set getVotingQuestions() { + return votingQuestions; + } +} diff --git a/src/main/java/com/faforever/api/data/listeners/VotingChoiceEnricher.java b/src/main/java/com/faforever/api/data/listeners/VotingChoiceEnricher.java new file mode 100644 index 000000000..05b461037 --- /dev/null +++ b/src/main/java/com/faforever/api/data/listeners/VotingChoiceEnricher.java @@ -0,0 +1,34 @@ +package com.faforever.api.data.listeners; + +import com.faforever.api.data.domain.VotingAnswer; +import com.faforever.api.data.domain.VotingChoice; +import com.google.common.base.Strings; +import org.springframework.context.support.MessageSourceAccessor; +import org.springframework.stereotype.Component; + +import javax.inject.Inject; +import javax.persistence.PostLoad; +import java.util.Set; + +@Component +public class VotingChoiceEnricher { + private static MessageSourceAccessor messageSourceAccessor; + + @Inject + public void init(MessageSourceAccessor messageSourceAccessor) { + VotingChoiceEnricher.messageSourceAccessor = messageSourceAccessor; + } + + @PostLoad + public void enhance(VotingChoice votingChoice) { + Boolean revealWinner = votingChoice.getVotingQuestion().getVotingSubject().getRevealWinner(); + Set votingAnswers = votingChoice.getVotingAnswers(); + votingChoice.setNumberOfAnswers(revealWinner && votingAnswers != null ? votingAnswers.size() : 0); + votingChoice.setChoiceText(messageSourceAccessor.getMessage(votingChoice.getChoiceTextKey())); + if (!Strings.isNullOrEmpty(votingChoice.getDescriptionKey())) { + votingChoice.setDescription(messageSourceAccessor.getMessage(votingChoice.getDescriptionKey())); + } + } + + +} diff --git a/src/main/java/com/faforever/api/data/listeners/VotingQuestionEnricher.java b/src/main/java/com/faforever/api/data/listeners/VotingQuestionEnricher.java new file mode 100644 index 000000000..49dcad74e --- /dev/null +++ b/src/main/java/com/faforever/api/data/listeners/VotingQuestionEnricher.java @@ -0,0 +1,39 @@ +package com.faforever.api.data.listeners; + +import com.faforever.api.data.domain.VotingQuestion; +import com.google.common.base.Strings; +import org.springframework.context.support.MessageSourceAccessor; +import org.springframework.stereotype.Component; + +import javax.inject.Inject; +import javax.persistence.PostLoad; + +@Component +public class VotingQuestionEnricher { + private static MessageSourceAccessor messageSourceAccessor; + + @Inject + public void init(MessageSourceAccessor messageSourceAccessor) { + VotingQuestionEnricher.messageSourceAccessor = messageSourceAccessor; + } + + @PostLoad + public void enhance(VotingQuestion votingQuestion) { + if (votingQuestion.getVotingChoices() != null) { + int numberOfAnswers = votingQuestion.getVotingChoices().stream() + .mapToInt(votingChoice -> { + if (votingChoice.getVotingAnswers() == null) { + return 0; + } + return votingChoice.getVotingAnswers().size(); + }) + .sum(); + votingQuestion.setNumberOfAnswers(numberOfAnswers); + } + votingQuestion.setQuestion(messageSourceAccessor.getMessage(votingQuestion.getQuestionKey())); + if (!Strings.isNullOrEmpty(votingQuestion.getDescriptionKey())) { + votingQuestion.setDescription(messageSourceAccessor.getMessage(votingQuestion.getDescriptionKey())); + } + } + +} diff --git a/src/main/java/com/faforever/api/data/listeners/VotingSubjectEnricher.java b/src/main/java/com/faforever/api/data/listeners/VotingSubjectEnricher.java new file mode 100644 index 000000000..501d3ba47 --- /dev/null +++ b/src/main/java/com/faforever/api/data/listeners/VotingSubjectEnricher.java @@ -0,0 +1,133 @@ +package com.faforever.api.data.listeners; + +import com.faforever.api.data.domain.VotingAnswer; +import com.faforever.api.data.domain.VotingChoice; +import com.faforever.api.data.domain.VotingQuestion; +import com.faforever.api.data.domain.VotingSubject; +import com.faforever.api.error.ApiException; +import com.faforever.api.error.Error; +import com.faforever.api.error.ErrorCode; +import com.faforever.api.voting.VotingQuestionRepository; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; +import org.springframework.context.support.MessageSourceAccessor; +import org.springframework.stereotype.Component; + +import javax.inject.Inject; +import javax.persistence.PostLoad; +import javax.persistence.PreUpdate; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static java.util.stream.Collectors.toList; + +@Component +public class VotingSubjectEnricher { + private static MessageSourceAccessor messageSourceAccessor; + private static VotingQuestionRepository votingQuestionRepository; + + + @Inject + public void init(MessageSourceAccessor messageSourceAccessor, VotingQuestionRepository votingQuestionRepository) { + VotingSubjectEnricher.messageSourceAccessor = messageSourceAccessor; + VotingSubjectEnricher.votingQuestionRepository = votingQuestionRepository; + } + + @PreUpdate + public void preUpdate(VotingSubject votingSubject) { + if (votingSubject.getEndOfVoteTime().isAfter(OffsetDateTime.now()) && votingSubject.getRevealWinner()) { + throw new ApiException(new Error(ErrorCode.CAN_NOT_REVEAL_RESULTS_WHEN_VOTING_IS_NOT_FINISHED)); + } + updateWinners(votingSubject); + } + + + private void updateWinners(VotingSubject votingSubject) { + votingSubject.getVotingQuestions().forEach(this::calculateWinners); + } + + @PostLoad + public void enhance(VotingSubject votingSubject) { + votingSubject.setNumberOfVotes(votingSubject.getVotes().size()); + votingSubject.setSubject(messageSourceAccessor.getMessage(votingSubject.getSubjectKey())); + String descriptionKey = votingSubject.getDescriptionKey(); + if (!Strings.isNullOrEmpty(descriptionKey)) { + votingSubject.setDescription(messageSourceAccessor.getMessage(votingSubject.getDescriptionKey())); + } + } + + @VisibleForTesting + protected void calculateWinners(VotingQuestion votingQuestion) { + VotingSubject votingSubject = votingQuestion.getVotingSubject(); + boolean ended = votingSubject.getEndOfVoteTime().isBefore(OffsetDateTime.now()); + List winners = votingQuestion.getWinners(); + if ((winners == null || winners.isEmpty()) && ended && votingSubject.getRevealWinner()) { + votingQuestion.setWinners(getWinners(votingQuestion)); + } else { + votingQuestion.setWinners(Collections.emptyList()); + } + } + + private List getWinners(VotingQuestion votingQuestion) { + if (!votingQuestion.isAlternativeQuestion()) { + OptionalInt max = votingQuestion.getVotingChoices().stream() + .mapToInt(value -> value.getVotingAnswers().size()).max(); + if (max.isPresent()) { + return votingQuestion.getVotingChoices().stream() + .filter(votingChoice -> votingChoice.getVotingAnswers().size() == max.getAsInt()).collect(toList()); + } + return Collections.emptyList(); + } + + //All the answers sorted by their choice, but only those that are the 1st choice + Map> votersByChoice = votingQuestion.getVotingChoices().stream() + .collect(Collectors.toMap( + Function.identity(), + choice -> new ArrayList<>(choice.getVotingAnswers().stream() + .filter(votingAnswer -> votingAnswer.getAlternativeOrdinal() == 0) + .collect(toList())) + )); + + while (votersByChoice.size() > 1) { + OptionalInt min = votersByChoice.values().stream().mapToInt(List::size).min(); + List candidatesToEliminate = votersByChoice.entrySet().stream() + .filter(votingChoiceListEntry -> votingChoiceListEntry.getValue().size() == min.getAsInt()) + .map(Entry::getKey) + .collect(toList()); + + if (candidatesToEliminate.size() == votersByChoice.size()) { + //We got a problem here, we would eliminate all the candidates if we went on normally + return candidatesToEliminate; + } + + candidatesToEliminate.forEach(candidate -> { + List votingAnswersForCandidate = votersByChoice.get(candidate); + + //Lets distribute the answers of the candidate that is eliminated + votingAnswersForCandidate.forEach(votingAnswer -> { + int newAlternativeOrdinal = votingAnswer.getAlternativeOrdinal() + 1; + votingAnswer.getVote().getVotingAnswers().stream() + .filter(votingAnswer1 -> votingAnswer1.getVotingChoice().getVotingQuestion().equals(votingAnswer.getVotingChoice().getVotingQuestion()) && votingAnswer1.getAlternativeOrdinal() == newAlternativeOrdinal) + .findFirst() + .ifPresent(votingAnswer1 -> { + VotingChoice votingChoice1 = votingAnswer1.getVotingChoice(); + votersByChoice.get(votingChoice1).add(votingAnswer1); + }); + }); + + votersByChoice.remove(candidate); + }); + } + Optional>> first = votersByChoice.entrySet().stream().findFirst(); + + return first.map(votingChoiceListEntry -> Collections.singletonList(votingChoiceListEntry.getKey())).orElse(Collections.emptyList()); + } +} diff --git a/src/main/java/com/faforever/api/error/ErrorCode.java b/src/main/java/com/faforever/api/error/ErrorCode.java index 411699c45..44af855cb 100644 --- a/src/main/java/com/faforever/api/error/ErrorCode.java +++ b/src/main/java/com/faforever/api/error/ErrorCode.java @@ -78,7 +78,15 @@ public enum ErrorCode { AVATAR_NAME_CONFLICT(168, "Invalid avatar file name", "Avatar file name ''{0}'' already exists."), AVATAR_IN_USE(169, "Avatar in use", "Could not delete avatar {0, number}. Avatar still in use."), ENTITY_NOT_FOUND(170, "Entity not found", "Entity with id: {0} not found."), - INVALID_AVATAR_DIMENSION(171, "Invalid avatar dimensions", "Avatar dimensions must be {0, number}x{1, number}, was: {2, number}x{3, number}."); + INVALID_AVATAR_DIMENSION(171, "Invalid avatar dimensions", "Avatar dimensions must be {0, number}x{1, number}, was: {2, number}x{3, number}."), + VOTED_TWICE(172, "You can not vote twice", "There was a vote found for your user and this subject"), + NOT_ENOUGH_GAMES(173, "You have not got enough games to vote", "You have ''{0}'' games but you need ''{1}''."), + TOO_MANY_ANSWERS(174, "You have to many answers selected in question", "You selected ''{0}'' but you can only select ''{1}''."), + VOTE_DID_NOT_START_YET(175, "Vote did not start yet.", "You can not vote if the voting has not started. Vote starts at ''{0}''."), + VOTE_ALREADY_ENDED(176, "Vote ended already", "You can not vote if the voting has already finished. Vote ended at ''{0}''."), + MALFORMATTED_ALTERNATIVE_ORDINALS(177, "You have malformatted alternative ordinals", "Check your preference ordinals. You might have left out an option in between."), + VOTED_TWICE_ON_ONE_OPTION(178, "Selected one option twice", "You can not vote twice for one option. Voting Choice with id ''{0}'' was selected twice."), + CAN_NOT_REVEAL_RESULTS_WHEN_VOTING_IS_NOT_FINISHED(179, "Vote still ongoing", "You can reveal results when voting is ongoing. Please set reveal results only after voting finished."); private final int code; private final String title; @@ -93,4 +101,5 @@ public enum ErrorCode { public String codeAsString() { return String.valueOf(code); } + } diff --git a/src/main/java/com/faforever/api/game/GamePlayerStatsRepository.java b/src/main/java/com/faforever/api/game/GamePlayerStatsRepository.java new file mode 100644 index 000000000..1c139dcb3 --- /dev/null +++ b/src/main/java/com/faforever/api/game/GamePlayerStatsRepository.java @@ -0,0 +1,10 @@ +package com.faforever.api.game; + +import com.faforever.api.data.domain.GamePlayerStats; +import com.faforever.api.data.domain.Player; +import com.faforever.api.data.domain.Validity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface GamePlayerStatsRepository extends JpaRepository { + int countByPlayerAndGameValidity(Player player, Validity validity); +} diff --git a/src/main/java/com/faforever/api/security/OAuthScope.java b/src/main/java/com/faforever/api/security/OAuthScope.java index 2ae1f340c..0d5b7a609 100644 --- a/src/main/java/com/faforever/api/security/OAuthScope.java +++ b/src/main/java/com/faforever/api/security/OAuthScope.java @@ -16,7 +16,9 @@ public enum OAuthScope { UPLOAD_MOD(OAuthScope._UPLOAD_MOD, "Upload mods"), UPLOAD_AVATAR(OAuthScope._UPLOAD_AVATAR, "Upload avatars"), WRITE_ACCOUNT_DATA(OAuthScope._WRITE_ACCOUNT_DATA, "Edit account data"), - EDIT_CLAN_DATA(OAuthScope._EDIT_CLAN_DATA, "Edit clan data"); + EDIT_CLAN_DATA(OAuthScope._EDIT_CLAN_DATA, "Edit clan data"), + VOTE(OAuthScope._VOTE, "Vote"); + public static final String _PUBLIC_PROFILE = "public_profile"; public static final String _CREATE_USER = "create_user"; @@ -29,6 +31,7 @@ public enum OAuthScope { public static final String _UPLOAD_AVATAR = "upload_avatar"; public static final String _WRITE_ACCOUNT_DATA = "write_account_data"; public static final String _EDIT_CLAN_DATA = "edit_clan_data"; + public static final String _VOTE = "vote"; private static final Map fromString; diff --git a/src/main/java/com/faforever/api/voting/VoteRepository.java b/src/main/java/com/faforever/api/voting/VoteRepository.java new file mode 100644 index 000000000..4cc2943e9 --- /dev/null +++ b/src/main/java/com/faforever/api/voting/VoteRepository.java @@ -0,0 +1,15 @@ +package com.faforever.api.voting; + +import com.faforever.api.data.domain.Player; +import com.faforever.api.data.domain.Vote; +import com.faforever.api.data.domain.VotingSubject; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.Set; + +public interface VoteRepository extends JpaRepository { + Optional findByPlayerAndVotingSubjectId(Player player, int votingSubjectId); + + Set findByVotingSubject(VotingSubject votingSubject); +} diff --git a/src/main/java/com/faforever/api/voting/VotingAnswerRepository.java b/src/main/java/com/faforever/api/voting/VotingAnswerRepository.java new file mode 100644 index 000000000..aa1d9b139 --- /dev/null +++ b/src/main/java/com/faforever/api/voting/VotingAnswerRepository.java @@ -0,0 +1,7 @@ +package com.faforever.api.voting; + +import com.faforever.api.data.domain.VotingAnswer; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface VotingAnswerRepository extends JpaRepository { +} diff --git a/src/main/java/com/faforever/api/voting/VotingChoiceRepository.java b/src/main/java/com/faforever/api/voting/VotingChoiceRepository.java new file mode 100644 index 000000000..4b7344e5e --- /dev/null +++ b/src/main/java/com/faforever/api/voting/VotingChoiceRepository.java @@ -0,0 +1,7 @@ +package com.faforever.api.voting; + +import com.faforever.api.data.domain.VotingChoice; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface VotingChoiceRepository extends JpaRepository { +} diff --git a/src/main/java/com/faforever/api/voting/VotingController.java b/src/main/java/com/faforever/api/voting/VotingController.java new file mode 100644 index 000000000..233568bee --- /dev/null +++ b/src/main/java/com/faforever/api/voting/VotingController.java @@ -0,0 +1,78 @@ +package com.faforever.api.voting; + +import com.faforever.api.data.domain.Vote; +import com.faforever.api.data.domain.VotingSubject; +import com.faforever.api.player.PlayerService; +import com.faforever.api.security.OAuthScope; +import com.google.common.base.Strings; +import io.swagger.annotations.ApiOperation; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +import javax.inject.Inject; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; + +@RestController +@RequestMapping(path = VotingController.PATH) +public class VotingController { + static final String PATH = "/voting"; + private static final String JSON_API_MEDIA_TYPE = "application/vnd.api+json"; + private final VotingService votingService; + private final PlayerService playerService; + + @Inject + public VotingController(VotingService votingService, PlayerService playerService) { + this.votingService = votingService; + this.playerService = playerService; + } + + @ApiOperation(value = "Post a vote") + @PreAuthorize("#oauth2.hasScope('" + OAuthScope._VOTE + "')") + @RequestMapping(path = "/vote", method = RequestMethod.POST, produces = JSON_API_MEDIA_TYPE) + public void postVote(@RequestBody Vote vote, Authentication authentication) { + votingService.saveVote(vote, playerService.getPlayer(authentication)); + } + + @ApiOperation(value = "See if user can vote on a subject") + @RequestMapping(path = "/votingSubjectsAbleToVote", method = RequestMethod.GET, produces = JSON_API_MEDIA_TYPE) + public void votingSubjectsAbleTo(HttpServletResponse response, Authentication authentication, HttpServletRequest request) throws IOException { + List votingSubjects = votingService.votingSubjectsAbleToVote(playerService.getPlayer(authentication)); + redirectToFilteredVotingSubjects(response, votingSubjects, request); + } + + private void redirectToFilteredVotingSubjects(HttpServletResponse response, List votingSubjects, HttpServletRequest request) throws IOException { + boolean empty = true; + + StringBuilder filter = new StringBuilder(); + filter.append("id=in=("); + for (VotingSubject votingSubject : votingSubjects) { + if (empty) { + empty = false; + } else { + filter.append(','); + } + filter.append(votingSubject.getId()); + } + + if (empty) { + filter.append("-1"); + } + filter.append(')'); + + String queryString = request.getQueryString(); + + if (!Strings.isNullOrEmpty(queryString)) { + response.sendRedirect(String.format("/data/votingSubject?%s&filter=%s", queryString, filter)); + } else { + response.sendRedirect(String.format("/data/votingSubject?filter=%s", filter)); + } + } +} + diff --git a/src/main/java/com/faforever/api/voting/VotingQuestionRepository.java b/src/main/java/com/faforever/api/voting/VotingQuestionRepository.java new file mode 100644 index 000000000..936b0c692 --- /dev/null +++ b/src/main/java/com/faforever/api/voting/VotingQuestionRepository.java @@ -0,0 +1,11 @@ +package com.faforever.api.voting; + + +import com.faforever.api.data.domain.VotingQuestion; +import org.springframework.data.jpa.repository.JpaRepository; + + +public interface VotingQuestionRepository extends JpaRepository { + +} + diff --git a/src/main/java/com/faforever/api/voting/VotingService.java b/src/main/java/com/faforever/api/voting/VotingService.java new file mode 100644 index 000000000..baee7b03a --- /dev/null +++ b/src/main/java/com/faforever/api/voting/VotingService.java @@ -0,0 +1,131 @@ +package com.faforever.api.voting; + +import com.faforever.api.data.domain.Player; +import com.faforever.api.data.domain.Validity; +import com.faforever.api.data.domain.Vote; +import com.faforever.api.data.domain.VotingAnswer; +import com.faforever.api.data.domain.VotingChoice; +import com.faforever.api.data.domain.VotingSubject; +import com.faforever.api.error.ApiException; +import com.faforever.api.error.Error; +import com.faforever.api.error.ErrorCode; +import com.faforever.api.game.GamePlayerStatsRepository; +import com.faforever.api.player.PlayerRepository; +import org.springframework.stereotype.Service; +import org.springframework.util.Assert; + +import javax.transaction.Transactional; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +public class VotingService { + private final VoteRepository voteRepository; + private final VotingSubjectRepository votingSubjectRepository; + private final GamePlayerStatsRepository gamePlayerStatsRepository; + private final VotingChoiceRepository votingChoiceRepository; + private final PlayerRepository playerRepository; + + public VotingService(VoteRepository voteRepository, VotingSubjectRepository votingSubjectRepository, GamePlayerStatsRepository gamePlayerStatsRepository, VotingChoiceRepository votingChoiceRepository, PlayerRepository playerRepository) { + this.voteRepository = voteRepository; + this.votingSubjectRepository = votingSubjectRepository; + this.gamePlayerStatsRepository = gamePlayerStatsRepository; + this.votingChoiceRepository = votingChoiceRepository; + this.playerRepository = playerRepository; + } + + @Transactional + public void saveVote(Vote vote, Player player) { + vote.setPlayer(player); + Assert.notNull(vote.getVotingSubject(), "You must specify a subject"); + List errors = ableToVote(player, vote.getVotingSubject().getId()); + + if (vote.getVotingAnswers() == null) { + vote.setVotingAnswers(Collections.emptySet()); + } + + VotingSubject subject = votingSubjectRepository.findOne(vote.getVotingSubject().getId()); + + vote.getVotingAnswers().forEach(votingAnswer -> { + VotingChoice votingChoice = votingAnswer.getVotingChoice(); + VotingChoice one = votingChoiceRepository.findOne(votingChoice.getId()); + votingAnswer.setVotingChoice(one); + votingAnswer.setVote(vote); + }); + + vote.getVotingAnswers().forEach(votingAnswer -> { + long count = vote.getVotingAnswers().stream() + .filter(votingAnswer1 -> votingAnswer1.getVotingChoice().equals(votingAnswer.getVotingChoice())) + .count(); + if (count > 1) { + errors.add(new Error(ErrorCode.VOTED_TWICE_ON_ONE_OPTION, votingAnswer.getVotingChoice().getId())); + } + }); + + subject.getVotingQuestions().forEach(votingQuestion -> { + List votingAnswers = vote.getVotingAnswers().stream() + .filter(votingAnswer -> votingAnswer.getVotingChoice().getVotingQuestion().equals(votingQuestion)) + .collect(Collectors.toList()); + long countOfAnswers = votingAnswers.size(); + int maxAnswers = votingQuestion.getMaxAnswers(); + if (maxAnswers < countOfAnswers) { + errors.add(new Error(ErrorCode.TOO_MANY_ANSWERS, countOfAnswers, maxAnswers)); + } + + if (votingQuestion.isAlternativeQuestion()) { + for (int i = 0; i < countOfAnswers; i++) { + int finalI = i; + long answersWithOrdinal = votingAnswers.stream() + .filter(votingAnswer -> votingAnswer.getAlternativeOrdinal() == Integer.valueOf(finalI)) + .count(); + if (answersWithOrdinal == 1) { + continue; + } + errors.add(new Error(ErrorCode.MALFORMATTED_ALTERNATIVE_ORDINALS)); + } + } + }); + if (!errors.isEmpty()) { + throw new ApiException(errors.toArray(new Error[0])); + } + voteRepository.save(vote); + } + + List ableToVote(Player player, int votingSubjectId) { + List errors = new ArrayList<>(); + Optional byPlayerAndVotingSubject = voteRepository.findByPlayerAndVotingSubjectId(player, votingSubjectId); + if (byPlayerAndVotingSubject.isPresent()) { + errors.add(new Error(ErrorCode.VOTED_TWICE)); + } + VotingSubject subject = votingSubjectRepository.findOne(votingSubjectId); + int gamesPlayed = gamePlayerStatsRepository.countByPlayerAndGameValidity(player, Validity.VALID); + + if (subject.getBeginOfVoteTime().isAfter(OffsetDateTime.now())) { + errors.add(new Error(ErrorCode.VOTE_DID_NOT_START_YET, subject.getBeginOfVoteTime())); + } + + if (subject.getEndOfVoteTime().isBefore(OffsetDateTime.now())) { + errors.add(new Error(ErrorCode.VOTE_ALREADY_ENDED, subject.getEndOfVoteTime())); + } + + if (gamesPlayed < subject.getMinGamesToVote()) { + errors.add(new Error(ErrorCode.NOT_ENOUGH_GAMES, gamesPlayed, subject.getMinGamesToVote())); + } + return errors; + } + + List votingSubjectsAbleToVote(int userId) { + return votingSubjectsAbleToVote(playerRepository.findOne(userId)); + } + + List votingSubjectsAbleToVote(Player player) { + List all = votingSubjectRepository.findAll(); + return all.stream() + .filter(votingSubject -> ableToVote(player, votingSubject.getId()).isEmpty()) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/faforever/api/voting/VotingSubjectRepository.java b/src/main/java/com/faforever/api/voting/VotingSubjectRepository.java new file mode 100644 index 000000000..f75f887d0 --- /dev/null +++ b/src/main/java/com/faforever/api/voting/VotingSubjectRepository.java @@ -0,0 +1,7 @@ +package com.faforever.api.voting; + +import com.faforever.api.data.domain.VotingSubject; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface VotingSubjectRepository extends JpaRepository { +} diff --git a/src/main/resources/config/application.yml b/src/main/resources/config/application.yml index 881b5f388..9bfe68f68 100644 --- a/src/main/resources/config/application.yml +++ b/src/main/resources/config/application.yml @@ -9,7 +9,7 @@ faf-api: challonge: key: ${CHALLONGE_KEY:} database: - schema-version: ${DATABASE_SCHEMA_VERSION:48} + schema-version: ${DATABASE_SCHEMA_VERSION:51} mautic: base-url: ${MAUTIC_BASE_URL:false} client-id: ${MAUTIC_CLIENT_ID:false} diff --git a/src/test/java/com/faforever/api/data/listeners/VotingSubjectEnricherTest.java b/src/test/java/com/faforever/api/data/listeners/VotingSubjectEnricherTest.java new file mode 100644 index 000000000..aace35262 --- /dev/null +++ b/src/test/java/com/faforever/api/data/listeners/VotingSubjectEnricherTest.java @@ -0,0 +1,185 @@ +package com.faforever.api.data.listeners; + +import com.faforever.api.data.domain.Player; +import com.faforever.api.data.domain.Vote; +import com.faforever.api.data.domain.VotingAnswer; +import com.faforever.api.data.domain.VotingChoice; +import com.faforever.api.data.domain.VotingQuestion; +import com.faforever.api.data.domain.VotingSubject; +import com.faforever.api.voting.VotingQuestionRepository; +import org.hamcrest.Matchers; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.support.MessageSourceAccessor; + +import java.time.OffsetDateTime; +import java.util.Collections; +import java.util.HashSet; + +import static org.hamcrest.Matchers.hasItem; +import static org.junit.Assert.assertThat; + +@RunWith(MockitoJUnitRunner.class) +public class VotingSubjectEnricherTest { + private VotingSubjectEnricher instance; + @Mock + private MessageSourceAccessor messageSourceAccessor; + @Autowired + private VotingQuestionRepository votingQuestionRepository; + + @Before + public void setUp() { + instance = new VotingSubjectEnricher(); + instance.init(messageSourceAccessor, votingQuestionRepository); + } + + @Test + public void testQuestionEnhancing() { + VotingQuestion votingQuestion = new VotingQuestion(); + votingQuestion.setAlternativeQuestion(true); + votingQuestion.setQuestionKey("abc"); + VotingSubject votingSubject = new VotingSubject(); + votingSubject.setEndOfVoteTime(OffsetDateTime.MIN); + votingSubject.setRevealWinner(true); + votingQuestion.setVotingSubject(votingSubject); + + Vote vote1 = new Vote(); + Player player1 = new Player(); + vote1.setPlayer(player1); + + Vote vote2 = new Vote(); + Player player2 = new Player(); + vote2.setPlayer(player2); + + Vote vote3 = new Vote(); + Player player3 = new Player(); + vote1.setPlayer(player3); + + Vote vote4 = new Vote(); + Player player4 = new Player(); + vote1.setPlayer(player4); + + Vote vote5 = new Vote(); + Player player5 = new Player(); + vote1.setPlayer(player5); + + VotingChoice votingChoice = new VotingChoice(); + votingChoice.setId(1); + votingChoice.setVotingQuestion(votingQuestion); + + addAnswerToChoice(votingChoice, votingQuestion, vote1, 0); + addAnswerToChoice(votingChoice, votingQuestion, vote2, 0); + + VotingChoice votingChoice2 = new VotingChoice(); + votingChoice2.setId(2); + votingChoice2.setVotingQuestion(votingQuestion); + + addAnswerToChoice(votingChoice2, votingQuestion, vote3, 0); + addAnswerToChoice(votingChoice2, votingQuestion, vote4, 0); + addAnswerToChoice(votingChoice2, votingQuestion, vote5, 1); + + VotingChoice votingChoice3 = new VotingChoice(); + votingChoice3.setId(3); + votingChoice3.setVotingQuestion(votingQuestion); + + addAnswerToChoice(votingChoice2, votingQuestion, vote5, 0); + + instance.calculateWinners(votingQuestion); + + assertThat(votingQuestion.getWinners(), hasItem(votingChoice2)); + } + + + @Test + public void testQuestionEnhancingDraw() { + VotingQuestion votingQuestion = new VotingQuestion(); + votingQuestion.setAlternativeQuestion(true); + votingQuestion.setQuestionKey("abc"); + VotingSubject votingSubject = new VotingSubject(); + votingSubject.setEndOfVoteTime(OffsetDateTime.MIN); + votingSubject.setRevealWinner(true); + votingQuestion.setVotingSubject(votingSubject); + + Vote vote1 = new Vote(); + Player player1 = new Player(); + vote1.setPlayer(player1); + + Vote vote2 = new Vote(); + Player player2 = new Player(); + vote2.setPlayer(player2); + + Vote vote3 = new Vote(); + Player player3 = new Player(); + vote1.setPlayer(player3); + + Vote vote4 = new Vote(); + Player player4 = new Player(); + vote1.setPlayer(player4); + + Vote vote5 = new Vote(); + Player player5 = new Player(); + vote1.setPlayer(player5); + + Vote vote6 = new Vote(); + Player player6 = new Player(); + vote1.setPlayer(player6); + + + VotingChoice votingChoice = new VotingChoice(); + votingChoice.setId(1); + votingChoice.setVotingQuestion(votingQuestion); + + addAnswerToChoice(votingChoice, votingQuestion, vote1, 0); + addAnswerToChoice(votingChoice, votingQuestion, vote2, 0); + addAnswerToChoice(votingChoice, votingQuestion, vote6, 0); + + + VotingChoice votingChoice2 = new VotingChoice(); + votingChoice2.setId(2); + votingChoice2.setVotingQuestion(votingQuestion); + + addAnswerToChoice(votingChoice2, votingQuestion, vote4, 0); + addAnswerToChoice(votingChoice2, votingQuestion, vote3, 0); + addAnswerToChoice(votingChoice2, votingQuestion, vote5, 1); + + VotingChoice votingChoice3 = new VotingChoice(); + votingChoice3.setId(3); + votingChoice3.setVotingQuestion(votingQuestion); + + addAnswerToChoice(votingChoice2, votingQuestion, vote5, 0); + + instance.calculateWinners(votingQuestion); + + assertThat(votingQuestion.getWinners(), Matchers.allOf(hasItem(votingChoice2), hasItem(votingChoice))); + } + + private void addAnswerToChoice(VotingChoice votingChoice, VotingQuestion votingQuestion, Vote vote, int alternativeOrdinal) { + VotingAnswer votingAnswer = new VotingAnswer(); + votingAnswer.setAlternativeOrdinal(alternativeOrdinal); + votingAnswer.setVote(vote); + votingAnswer.setVotingChoice(votingChoice); + + if (vote.getVotingAnswers() != null) { + vote.getVotingAnswers().add(votingAnswer); + } else { + vote.setVotingAnswers(new HashSet<>(Collections.singleton(votingAnswer))); + } + + if (votingChoice.getVotingAnswers() != null) { + votingChoice.getVotingAnswers().add(votingAnswer); + } else { + votingChoice.setVotingAnswers(new HashSet<>(Collections.singleton(votingAnswer))); + } + + if (votingQuestion.getVotingChoices() != null) { + votingQuestion.getVotingChoices().add(votingChoice); + } else { + votingQuestion.setVotingChoices(new HashSet<>(Collections.singleton(votingChoice))); + } + } + +} diff --git a/src/test/java/com/faforever/api/voting/VotingServiceTest.java b/src/test/java/com/faforever/api/voting/VotingServiceTest.java new file mode 100644 index 000000000..f5c7e6cce --- /dev/null +++ b/src/test/java/com/faforever/api/voting/VotingServiceTest.java @@ -0,0 +1,262 @@ +package com.faforever.api.voting; + +import com.faforever.api.data.domain.Player; +import com.faforever.api.data.domain.Vote; +import com.faforever.api.data.domain.VotingAnswer; +import com.faforever.api.data.domain.VotingChoice; +import com.faforever.api.data.domain.VotingQuestion; +import com.faforever.api.data.domain.VotingSubject; +import com.faforever.api.error.ApiException; +import com.faforever.api.error.ErrorCode; +import com.faforever.api.game.GamePlayerStatsRepository; +import com.faforever.api.player.PlayerRepository; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import java.time.OffsetDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Optional; + +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class VotingServiceTest { + private VotingService instance; + @Mock + private VoteRepository voteRepository; + @Mock + private VotingSubjectRepository votingSubjectRepository; + @Mock + private GamePlayerStatsRepository gamePlayerStatsRepository; + @Mock + private VotingChoiceRepository votingChoiceRepository; + @Mock + private PlayerRepository playerRepository; + + @Before + public void setUp() { + instance = new VotingService(voteRepository, votingSubjectRepository, gamePlayerStatsRepository, votingChoiceRepository, playerRepository); + } + + @Test + public void saveVoteSuccessful() { + Vote vote = new Vote(); + VotingSubject votingSubject = new VotingSubject(); + votingSubject.setId(1); + votingSubject.setBeginOfVoteTime(OffsetDateTime.now()); + votingSubject.setEndOfVoteTime(OffsetDateTime.MAX); + VotingQuestion votingQuestion = new VotingQuestion(); + votingQuestion.setAlternativeQuestion(false); + votingSubject.setVotingQuestions(Collections.singleton(votingQuestion)); + + vote.setVotingSubject(votingSubject); + Player player = new Player(); + + when(voteRepository.findByPlayerAndVotingSubjectId(player, votingSubject.getId())).thenReturn(Optional.empty()); + when(votingSubjectRepository.findOne(votingSubject.getId())).thenReturn(votingSubject); + + instance.saveVote(vote, player); + verify(voteRepository).save(vote); + } + + + @Test + public void notSaveVoteIfUserVotedAlready() { + Vote vote = new Vote(); + VotingSubject votingSubject = new VotingSubject(); + votingSubject.setId(1); + votingSubject.setBeginOfVoteTime(OffsetDateTime.now()); + votingSubject.setEndOfVoteTime(OffsetDateTime.MAX); + VotingQuestion votingQuestion = new VotingQuestion(); + votingQuestion.setAlternativeQuestion(false); + votingSubject.setVotingQuestions(Collections.singleton(votingQuestion)); + + vote.setVotingSubject(votingSubject); + Player player = new Player(); + + when(voteRepository.findByPlayerAndVotingSubjectId(player, votingSubject.getId())).thenReturn(Optional.of(new Vote())); + when(votingSubjectRepository.findOne(votingSubject.getId())).thenReturn(votingSubject); + + try { + instance.saveVote(vote, player); + } catch (ApiException e) { + assertTrue(Arrays.stream(e.getErrors()).anyMatch(error -> error.getErrorCode().equals(ErrorCode.VOTED_TWICE))); + } + verify(voteRepository, never()).save(vote); + } + + @Test + public void saveVoteIfAlternativeOrdinalCorrect() { + VotingSubject votingSubject = new VotingSubject(); + votingSubject.setId(1); + votingSubject.setBeginOfVoteTime(OffsetDateTime.now()); + votingSubject.setEndOfVoteTime(OffsetDateTime.MAX); + + VotingQuestion votingQuestion = new VotingQuestion(); + votingQuestion.setAlternativeQuestion(true); + votingSubject.setVotingQuestions(Collections.singleton(votingQuestion)); + votingQuestion.setMaxAnswers(2); + + Vote vote = new Vote(); + + VotingAnswer votingAnswer = new VotingAnswer(); + VotingChoice votingChoice = new VotingChoice(); + votingChoice.setId(1); + votingChoice.setVotingQuestion(votingQuestion); + votingAnswer.setVotingChoice(votingChoice); + votingAnswer.setAlternativeOrdinal(0); + VotingAnswer votingAnswer2 = new VotingAnswer(); + VotingChoice votingChoice2 = new VotingChoice(); + votingChoice2.setId(2); + votingChoice2.setVotingQuestion(votingQuestion); + votingAnswer2.setVotingChoice(votingChoice2); + votingAnswer2.setAlternativeOrdinal(1); + + vote.setVotingAnswers(new HashSet<>(Arrays.asList(votingAnswer, votingAnswer2))); + + vote.setVotingSubject(votingSubject); + Player player = new Player(); + + when(voteRepository.findByPlayerAndVotingSubjectId(player, votingSubject.getId())).thenReturn(Optional.empty()); + when(votingSubjectRepository.findOne(votingSubject.getId())).thenReturn(votingSubject); + when(votingChoiceRepository.findOne(votingChoice.getId())).thenReturn(votingChoice); + when(votingChoiceRepository.findOne(votingChoice2.getId())).thenReturn(votingChoice2); + + instance.saveVote(vote, player); + verify(voteRepository).save(vote); + } + + @Test + public void notSaveVoteIfAlternativeOrdinalWrong() { + VotingSubject votingSubject = new VotingSubject(); + votingSubject.setId(1); + votingSubject.setBeginOfVoteTime(OffsetDateTime.now()); + votingSubject.setEndOfVoteTime(OffsetDateTime.MAX); + + VotingQuestion votingQuestion = new VotingQuestion(); + votingQuestion.setAlternativeQuestion(true); + votingSubject.setVotingQuestions(Collections.singleton(votingQuestion)); + votingQuestion.setMaxAnswers(2); + + Vote vote = new Vote(); + + VotingAnswer votingAnswer = new VotingAnswer(); + VotingChoice votingChoice = new VotingChoice(); + votingChoice.setId(1); + votingChoice.setVotingQuestion(votingQuestion); + votingAnswer.setVotingChoice(votingChoice); + votingAnswer.setAlternativeOrdinal(1); + VotingAnswer votingAnswer2 = new VotingAnswer(); + VotingChoice votingChoice2 = new VotingChoice(); + votingChoice2.setId(2); + votingChoice2.setVotingQuestion(votingQuestion); + votingAnswer2.setVotingChoice(votingChoice2); + votingAnswer2.setAlternativeOrdinal(1); + + vote.setVotingAnswers(new HashSet<>(Arrays.asList(votingAnswer, votingAnswer2))); + + vote.setVotingSubject(votingSubject); + Player player = new Player(); + + when(voteRepository.findByPlayerAndVotingSubjectId(player, votingSubject.getId())).thenReturn(Optional.empty()); + when(votingSubjectRepository.findOne(votingSubject.getId())).thenReturn(votingSubject); + when(votingChoiceRepository.findOne(votingChoice.getId())).thenReturn(votingChoice); + when(votingChoiceRepository.findOne(votingChoice2.getId())).thenReturn(votingChoice2); + + try { + instance.saveVote(vote, player); + } catch (ApiException e) { + assertTrue(Arrays.stream(e.getErrors()).anyMatch(error -> error.getErrorCode().equals(ErrorCode.MALFORMATTED_ALTERNATIVE_ORDINALS))); + } + verify(voteRepository, never()).save(vote); + } + + @Test + public void notSaveVoteOnTooManyAnswers() { + VotingSubject votingSubject = new VotingSubject(); + votingSubject.setId(1); + votingSubject.setBeginOfVoteTime(OffsetDateTime.now()); + votingSubject.setEndOfVoteTime(OffsetDateTime.MAX); + + VotingQuestion votingQuestion = new VotingQuestion(); + votingQuestion.setAlternativeQuestion(true); + votingSubject.setVotingQuestions(Collections.singleton(votingQuestion)); + votingQuestion.setMaxAnswers(1); + + Vote vote = new Vote(); + + VotingAnswer votingAnswer = new VotingAnswer(); + VotingChoice votingChoice = new VotingChoice(); + votingChoice.setId(1); + votingChoice.setVotingQuestion(votingQuestion); + votingAnswer.setVotingChoice(votingChoice); + VotingAnswer votingAnswer2 = new VotingAnswer(); + VotingChoice votingChoice2 = new VotingChoice(); + votingChoice2.setId(2); + votingChoice2.setVotingQuestion(votingQuestion); + votingAnswer2.setVotingChoice(votingChoice2); + + vote.setVotingAnswers(new HashSet<>(Arrays.asList(votingAnswer, votingAnswer2))); + + vote.setVotingSubject(votingSubject); + Player player = new Player(); + + when(voteRepository.findByPlayerAndVotingSubjectId(player, votingSubject.getId())).thenReturn(Optional.empty()); + when(votingSubjectRepository.findOne(votingSubject.getId())).thenReturn(votingSubject); + when(votingChoiceRepository.findOne(votingChoice.getId())).thenReturn(votingChoice); + when(votingChoiceRepository.findOne(votingChoice2.getId())).thenReturn(votingChoice2); + + try { + instance.saveVote(vote, player); + } catch (ApiException e) { + assertTrue(Arrays.stream(e.getErrors()).anyMatch(error -> error.getErrorCode().equals(ErrorCode.TOO_MANY_ANSWERS))); + } + verify(voteRepository, never()).save(vote); + } + + @Test + public void notSaveOnVoteTwiceInOneOption() { + VotingSubject votingSubject = new VotingSubject(); + votingSubject.setId(1); + votingSubject.setBeginOfVoteTime(OffsetDateTime.now()); + votingSubject.setEndOfVoteTime(OffsetDateTime.MAX); + + VotingQuestion votingQuestion = new VotingQuestion(); + votingQuestion.setAlternativeQuestion(true); + votingSubject.setVotingQuestions(Collections.singleton(votingQuestion)); + votingQuestion.setMaxAnswers(2); + + Vote vote = new Vote(); + + VotingAnswer votingAnswer = new VotingAnswer(); + VotingChoice votingChoice = new VotingChoice(); + votingChoice.setId(1); + votingChoice.setVotingQuestion(votingQuestion); + votingAnswer.setVotingChoice(votingChoice); + VotingAnswer votingAnswer2 = new VotingAnswer(); + votingAnswer2.setVotingChoice(votingChoice); + + vote.setVotingAnswers(new HashSet<>(Arrays.asList(votingAnswer, votingAnswer2))); + + vote.setVotingSubject(votingSubject); + Player player = new Player(); + + when(voteRepository.findByPlayerAndVotingSubjectId(player, votingSubject.getId())).thenReturn(Optional.empty()); + when(votingSubjectRepository.findOne(votingSubject.getId())).thenReturn(votingSubject); + when(votingChoiceRepository.findOne(votingChoice.getId())).thenReturn(votingChoice); + try { + instance.saveVote(vote, player); + } catch (ApiException e) { + assertTrue(Arrays.stream(e.getErrors()).anyMatch(error -> error.getErrorCode().equals(ErrorCode.VOTED_TWICE_ON_ONE_OPTION))); + } + verify(voteRepository, never()).save(vote); + } +}