diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index d6ef7652..7bdd9809 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -26,24 +26,6 @@ jobs: - name: test run: go test -timeout 60s -race ./... - system-test: - name: System Test - runs-on: ubuntu-latest - steps: - - name: Check out code into the Go module directory - uses: actions/checkout@v5 - - - name: Start environment - run: docker compose -f "system_test/docker-compose.yml" up -d --build - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: "1.25" - - - name: run test - run: VOTE_SYSTEM_TEST=1 go test -timeout 60s ./system_test/ - docker: name: Docker runs-on: ubuntu-latest diff --git a/Migration.md b/Migration.md new file mode 100644 index 00000000..ea290b76 --- /dev/null +++ b/Migration.md @@ -0,0 +1,289 @@ +# Migration zum neuen Vote-Service + +Das alte System und das neue unterscheiden sich wesentlich. Eine eins zu eins +übersetzung der alten und neuen Felder ist nicht möglich. + + +## Bisheriges System + +Im bisherigen System hat jede Poll mehrere optionen. Diese werden über +`poll/option_ids` und `poll/global_option_id` verlinkt. Auch für motions wird +eine global-option angelegt, obwohl diese dort nie verwendet werden sollte. + +Jede option hat die Werte `yes`, `no` und `abstain`. Bei der Abstimmung gibt +jeder Nutzer für jede Option eine dieser drei Möglichkeiten an. Es gibt daher +pro User mehrere `vote` objekte. Diese werden immer als Ja-Nein-Enthaltung +gespeichert, auch wenn es eigentlich eine Auswahl ist. Die `option`-Objekte +enthalten das Result. Sie speichern in den `yes`-`no`-`abstain`-Feldern die +Summe aller auf sie bezogenen vote objekte. Die globale Obtion werden separat +gezählt. Daher als "Generelle Ablehnung", "Generelle Enthaltung" oder "Generelle +Zustimmung". + +Die vote objekte dienen lediglich der Anzeige, wer wie abgestimmt hat. Das Feld +`user_token` hilft dabei, verschiedene votes eines Nutzers zu bündeln, wenn die +user-id entfernt wurde. Bei nicht anonymisierten polls is `vote/user_id` der +Nutzer, für den die Stimme gezählt werden soll und `vote/ delegated_user_id`, +der die Stimme abgegeben hat. Werden pro Nutzer mehrere Stimmen erlaubt, dann +werden diese Stimmen in den vote-objekten über das vote-weight-feature +gebündelt. + +Die vom Nutzer eigentlich gesendeten Daten werden nicht gespeichert, sondern +interpretiert in die vote-objekte aufgeteilt. + +Fragen: Sind folgende Aussagen korrekt: +* Bei motion gibt es zwar immer eine global-option, diese wurde aber nie genutzt. + + + +## Neues System + +Im neuen System gibt es keine optionen. Stattdessen wird das Ergebnis direkt im +Feld `poll/result` gebündelt. Die Votes (jetzt ballot genannt) enthalten genau +die Daten, die ein Nutzer gesendet hat. Es gibt daher pro Poll und User nur ein +ballot-objekt. options gibt es nicht mehr als Collection. Jedoch werden bei +Wahlen die möglichen optionen in `poll_config_X/options` gespeichert. + +Eigentlich sollte das Feld `poll/result` redundant sein. Daher, es lässt sich zu +jeder Zeit aus den votes neu berechnen. Dies gilt nicht für manuelle polls und +es wäre in Ordnung, wenn es auch nicht für migrierte polls gilt. + + +## Übertragung + +Pro altem poll (`old`) wird ein neues Poll erstellt. Dieses hängt davon ab, ob +es eine motion, assignment oder topic poll ist. + + +### motion + +#### poll_config_approval + +``` +{ + id: kann automatisch erstellt werden, + poll_id: old.id + allow_abstain: if old.method == "YNA" then "" else false, +} +``` + +#### poll + +``` +{ + id: old.id, + title: old.title, + config_id: neu erstellte config-id, + visibility: old{"analog": "manually", "named": "open", "pseudoanonymous": "secret", "cryptographic": @panic(immpossible)}, + state: if old.state == "published" then "finished" else old.state, + result: see below, + published: old.state == "published", + allow_invalid: false, + allow_vote_split: false, + sequential_number: old.sequential_number, + content_object_id: old.content_object_id, + vote_ids: Egal in rel-db. Die Vote-objekte setzten die Relation, + voted_ids: [e.user_id for e in old.entitled_users_at_stop if e.voted], + entitled_group_ids: old.entitled_group_ids, + projection_ids: Egal in rel-db, + meeting_id: old.meeting_id +} +``` + + +#### poll/result + +Im alten System gibt es pro poll eine Option. Es gibt zusätzlich eine +global-option die jedoch ignoriert werden kann. Das neue `poll/result` +entspricht im wesentlichen dieser einen option. Sollte es mehr als eine option +geben, dann @panic. + +Wenn poll.state "created" oder "started" ist, dann ist poll/result leer. Ansonsten: + +`poll/result`: `{"yes": option.yes, "no": option.no, "abstain": option.abstain}` + +Bei manually polls werden invalide Stimmen unterstützt. Diese standen bisher in +poll.votesinvalid. In Zukunft können sie als weiteres attribute in `poll/result` +geschrieben werden. Allerdings nicht als decimal, sondern als int. `{..., +"invalid": 42}` + + +#### ballot + +Wenn poll.state "created" oder "started" ist, dann gibt es keine ballots. + +Wenn poll.visibility == "manually", dann wird kein ballot-objekt erstellt. + +Ansonsten: + +Im alten system gibt es pro user nur ein vote. Die Votes können über +old_poll.option_ids[0].vote_ids gefunden werden. + +``` +{ + id: kann automatisch erstellt werden ich würde nicht die alten ids verwenden, + weight: old.weight, + split: false, + value: old{"Y": "yes", "N": "no", "A": "abstain"} ansonsten @panic, + poll_id: old.poll_id, + acting_meeting_user_id: old.delegated_user_id -> jedoch seine meeting_user_id im poll.meeting, + represented_meeting_user_id: old.user_id -> jedoch seine meeting_user_id im poll.meeting. +} +``` + + +### assignment + +Wenn im alten system "Ja/Nein/Enthaltung pro Liste" ausgewählt wurde (Ich +glaube, dann gibt es nur eine option mit content_object_id auf +poll_candidate_list), dann behandle es wie bei motion. Daher mit "method": +"approval". Daher alles hier ignorieren und nur wie bei motion bearbeiten. + + +#### poll_config_rating_approval + +``` +{ + id: kann automatisch erstellt werden, + poll_id: old.id, + max_options_amount: old.max_votes_amount, + min_options_amount: old.min_votes_amount, + allow_abstain: if old.method == "YNA" then "" else false, +} +``` + + +#### poll_config_option + +Relevant sind die alten options (old_option) der poll. Für jede option sollte der Werte +option.content_object_id ein user-collection sein. Ansonsten @panic. Von diesem +Feld wird die user_id und zu dieser die meeting_user_id im entsprechenden meeting gesucht. + +``` +{ + id: + poll_config_id: poll_config_rating_approval/ID_VON_OBEN, + weight: old_option.weight, + meeting_user_id: old_option.content_object_id -> Davon user_id, von dieser die meeting_user_id herausfinden, +} +``` + + +#### Poll + +``` +{ + id: old.id, + title: old.title, + config_id: poll_config_rating_approval/ID_FROM_ABOVE, + visibility: old{"analog": "manually", "named": "open", "pseudoanonymous": "secret", "cryptographic": @panic(immpossible)}, + state: if old.state == "published" then "finished" else old.state, + result: see below, + published: old.state == "published", + allow_invalid: false, + allow_vote_split: false, + sequential_number: old.sequential_number, + content_object_id: old.content_object_id, + vote_ids: Egal in rel-db. Die Vote-objekte setzten die Relation, + voted_ids: [e.user_id for e in old.entitled_users_at_stop if e.voted], + entitled_group_ids: old.entitled_group_ids, + projection_ids: Egal in rel-db, + meeting_id: old.meeting_id +} +``` + + +#### poll/result + +Poll/result ist ein dict. Pro alter option gibt es einen Eintrag. Der Key ist +jeweils die oben angelegte poll_config_option-id. Die Werte "yes", "no" und "abstain" +werden als object übernommen. Zusätzlich wird bei manuellen polls als weiterer +Wert "invalid" aus der alten poll übernommen. + +`{"1":{"yes":"5","no":"1"},"2":{"yes":"1","abstain":"6"},"invalid":1}` + +Zusätzlich müssen die globalen Optionen in das Ergebnis mit einberechnet werden. +Daher die "yes"-"no"-"abstain" Werte der globalen Abstimmung wird bei jeder +Option addiert. + + +#### ballot + +Wenn poll.state "created" oder "started" ist, dann gibt es keine ballots. + +Wenn poll.visibility == "manually", dann wird kein ballot-objekt erstellt. + +Ansonsten: + +Im alten system gibt es pro user und option eine vote. Diese müssen in jeweils +ein ballot-objekt zusammengefasst werden. Die Votes können über +old_poll.option_ids.vote_ids gefunden werden. Werte mit identischem user-token +gehören zusammen. + +``` +{ + id: kann automatisch erstellt werden ich würde nicht die alten ids verwenden, + weight: old.weight (muss bei allen votes identisch sein, sonst @panic), + split: false, + value: Siehe unten, + poll_id: old.poll_id, + acting_meting_user_id: old.delegated_user_id -> Als meeting_user_id im entsprechenden meeting, + represented_meeting_user_id: old.user_id -> Als meeting_user_id im entsprechenden meeting +} +``` + + +`ballot/value` sieht wie folgt aus: +`{"option_id_A":"yes","option_idB":"abstain"}`. Daher pro option gibt es ein +Attribut als String. Der Wert wird genauso umgerechnet, wie bei motion: old{"Y": +"yes", "N": "no", "A": "abstain"} ansonsten @panic. + + +### topic + +Wird fast identisch wie bei assignment durchgeführt. + +Aber als key bei poll/result und config werden nicht die meeting_user_ids +verwendet, sondern option.text. + + + +## Informationen, die Verloren gehen: + +* Kurzlaufend oder langlaufend +* poll/description, sollte aber nie gesetzt gewesen sein +* Wahlverzeichet: entitled_users_at_stop. Es wird gerade nur übertragen, wer gewählt hat, aber nicht, wer stimmberechtigt war. +* Bei kummulativen Wahlen: poll.max_votes_per_option (daher, was die Einstellung war) +* Global options werden nicht mehr separat aufgeführt, sondern in das Ergebnis mit einberechnet. +* Poll.valid wurde bisher separat gezählt. In Zukunft muss es berechnet werden. Aus anzahl der votes minus result.invalid + + +## Einzelvergleich + +### Alte Felder + +* meeting/poll_default_backend was removed. No migration necessary. Just remove the value. +* motion/option_ids was removed. I think, it can just be removed (ignored) since it has no meaning. +* poll/description was removed. No migration needed. Was not used before. +* poll/type was renamed to poll/visibility and the values have changed. + * "analog" -> "manually" + * "named": Its not clear to me if old "named" values should be "named" in the new system or "open". I think, "open" is ok. + * "pseudoanonymous" -> "secret" + * "cryptographic": There should be no case. If so, "secret" can be used. + +* poll/backend: was removed. No migration necessary. +* poll/is_pseudoanonymized: Was removed. No migraton necessary. +* poll/pollmethod. Was removed, is now part of poll/config_id. +* poll/state: The value `published` was removed. polls in this state have to be set to `finished` and the field `poll/published` has to be set to true. +* poll/min_votes_amount, poll/max_votes_amount, poll/max_votes_per_option, poll/global_yes, poll/global_no, poll/global_abstain are removed. The new field poll/config has to be generated from them. +* poll/onehundred_percent_base has be removed. TODO after the client is done. +* poll/votesvalid, poll/votesinvalid, poll/votescast where removed. They have to be used to generate the field `poll/result`. +* poll/entitled_users_at_stop was removed. TODO after the client is done. +* poll/live_voting_enabled was removed. No migration needed, since there are no ongoing polls at the same time as the migration. +* poll/live_votes was removed. No migration needed. +* poll/crypt_key, poll/crypt_signature, poll/votes_raw, poll/votes_signature were removed: No migration needed. There was no case with this values. +* poll/option_ids, poll/global_option_id was removed: No migration needed. But are necessary to generate `poll/result`. +* The `option` collection was removed. No migration needed, but necessary to generate `poll/result`. +* vote/user_token was removed: No migration necessary +* vote/user_id was renamed to ballot/represented_meeting_user_id. +* vote/delegated_user_id was renamed to ballot/acting_meeting_user_id. +* vote/meeting_id was removed. No migration necessary. diff --git a/README.md b/README.md index 5f4beef8..bbf95482 100644 --- a/README.md +++ b/README.md @@ -1,142 +1,447 @@ # OpenSlides Vote Service -The Vote Service is part of the OpenSlides environments. It handles the votes -for an electonic poll. +The Vote Service is part of the OpenSlides environments. It is responsible for +the `poll`, `poll_config_X`, `poll_config_option` and `ballot` collections. It +handles the electronic voting. +The service has no internal state but uses the normal postgres database to save +the polls. -## Install and Start -The docker build uses the redis messaging service, the auth token and postgres. -Make sure the service inside the docker container can connect to this services. - The auth-secrets have to given as a file. +## Handlers -``` -docker build . --tag openslides-vote -printf "my_token_key" > auth_token_key -printf "my_cookie_key" > auth_cookie_key -docker run --network host -v $PWD/auth_token_key:/run/secrets/auth_token_key -v $PWD/auth_cookie_key:/run/secrets/auth_cookie_key openslides-vote -``` +All requests to the vote-service have to be POST-requests. -It uses the host network to connect to redis. +With the exception of the vote request, all requests can only be sent by a +manager. The permission depends on the field `content_object_id` of the +corresponding poll. +- motions: `motion.can_manage` +- assignments: `assignment.can_manage` +- topic: `poll.can_manage` -## Example Request with CURL +With the exception of the create request, all requests need an HTTP GET argument +in the url, to specify the poll-id. For example `/system/vote/update?id=23` -### Start a Poll -To start a poll a POST request has to be send to the start-url. +### Create a poll -To send the same request twice is ok. +`/system/vote/create` -``` -curl -X POST localhost:9013/internal/vote/start?id=1 -``` +The permissions for the create requests are a bit different, since the poll does +not exist in the database, when the request is sent. Therefore the permission +check depends on the field `content_object_id` in the request body. + +The request expects a body with the fields to create the poll: + +- `title` (required) +- `content_object_id` (required) +- `meeting_id` (required) +- `method` (required) +- `config` (required, depends on the [method](##Poll methods)) +- `visibility` (required) +- `entitled_group_ids` (only if visibility != manually) +- `live_voting_enabled` (only if visibility != manually) +- `result` (only if visibility == manually) +- `allow_vote_split` (default: false) + + +### Update a poll + +`/system/vote/update?id=XX` + +The fields `content_object_id` and `meeting_id` can not be changed. You have to +create a new poll to "update" them. + +The fields `method`, `config`, `visibility` and `entitled_group_ids` can only be +changed, before the poll has started. You can reset a poll to change this +values. + +The config can only be changed at a whole. If it is set in an update request, it +overwrites all config values. + +### Delete a poll + +`/system/vote/delete?id=XX` + +The delete request removes the poll and all its ballots in any state. Be careful. + + +### Start a poll + +`/system/vote/start?id=XX` +To start a poll means that the users can send their ballots. -### Send a Vote + +### Finalize a poll + +`/system/vote/finalize?id=XX` + +To finalize a poll means that users can not send their ballots anymore. It +creates the `poll/result` field. + +The request has two optional attributes: `publish` and `anonymize`. `publish` +sets the field `poll/state` to `published`. `anonymize` removes all user ids +from the corresponding `ballot` objects. + +The request can be send many times. It only creates the result the first time. +`publish` and `anonymize` can be used on a later request. + +To stop a poll and publish and anonymize it at the same time, the following +request can be used: + +`/system/vote/finalize?id=42&publish&anonymize` + + +### Reset a poll + +`/system/vote/reset?id=XX` + +Reset sets the state back to `created` and removes all `ballot` objects. + + +### Send a ballot A vote-request is a post request with the ballot as body. Only logged in users -can vote. The body has to be valid json. For example for the value 'Y' you have -to send `{"value":"Y"}`. +can vote. The body has to be valid json. -This handler is not idempotent. If the same user sends the same data twice, it -is an error. +The service distinguishes between two users on each vote-request. The acting +user is the request user, that sends the vote-request. The represented user is +the user, for whom the ballot is sent. Both users can be the same. -``` -curl localhost:9013/system/vote?id=1 -d '{"value":"Y"}' +The acting user has to be present in the meeting and needs the permission to +vote for the represented user. The represented user has to be in one of the +group of the field `poll/entitled_group_ids`. + +The request body has to be in the form: + +```json +{ + "meeting_user_id": 23, + "value": "Yes", + "split": false +} ``` +In this example, the request user would send the Vote `Yes` for the user with +the meeting_user_id 23. If the acting user and the represented user are the +same, then field `meeting_user_id` is not needed. `split` activates +[vote_split](#vote split) -### Stop the Poll +Valid values for the vote depend on the poll method. -With the stop request a poll is stopped and the vote values are returned. The -stop request is a POST request without a body. -A stop request can be send many times and will return the same data again. +### Read the poll -``` -curl -X POST localhost:9013/internal/vote/stop?id=1 -``` +The service only handles write requests. All Reads have to be done via the +autoupdate-service. -### Clear the poll +## poll/visibility -After a vote was stopped and the data is successfully stored in the datastore, a -clear request should be used to remove the data from the vote service. This is -especially important on fast votes to remove the mapping between the user id and -the vote. The clear requet is idempotent. +The field `poll/visibility` can be one of `manually`, `named`, `open` and +`secret`. -``` -curl -X POST localhost:9013/internal/vote/clear?id=1 -``` +### manually -### Clear all polls +Manually polls are polls without electronic voting. The result is calculated +from individual vote-requests from the users, but the manager sets the result +manually. -Only for development and debugging there is an internal route to clear all polls -at once. It there are many polls, this url could take a long time fully blocking -redis. Use this carfully. +Manual polls behave differently. When created, the field `poll/state` is set to +`finished`. The poll result can be set either with the create request or with an +update request. The server does not validate the field `poll/result`, but +accepts any string. -``` -curl -X POST localhost:9013/internal/vote/clear_all -``` +vote-requests are not possible. A finalize-request is possible, but only to set +the `poll/published` field. A reset-request sets/leaves the state at `finished`. -### Have I Voted +### named and open -A user can find out if he has voted for a list of polls. +At the moment, the visibilities `named` and `open` behave nearly the same. They +have two different meanings. In future versions, there will probably be +different features for this two modes. -``` -curl localhost:9013/system/vote/voted?ids=1,2,3 -``` +The value `named` means that the mapping between votes and users is not deleted +at the end. In a political context, a 'named' poll also means that eligible +voters are called individually, publicly, and one after another, and asked for +their vote. In the future, a feature could be considered where, for +`named`-polls, users cannot vote themselves, but instead the manager is guided +through a form in which they can enter the votes for all eligible voters one +after another. A `named`-poll can not be anonymized. -The responce is a json-object in the form like this: +The value `open` is likely the normal case for a vote. The mapping between votes +and users CAN be deleted afterwards with the `anonymize` flag of the finalize +handler. -``` + +### secret + +At the moment, a `secret`-poll is identical to an `open`- or `named`-poll. But +is handled differently in the autoupdate-service. The field +`ballot/acting_user_id` and `ballot/represented_user_id` get restricted for +everybody. + +In the future, these values will be used for crypto votes. See the entry in the +[wiki](https://github.com/OpenSlides/OpenSlides/wiki/DE%3AKonzept-geheime-Wahlen-mit-OpenSlides) + + +## Poll methods + +The values of `config`in the poll-create-request, `ballot/value` and +`poll/result` depend on the field poll method. + +The method of a poll can be calculated by looking at the collection-part of the +generic-relation-field `poll.config_id`. + + +### approval + +On an approval poll, the users can vote with `yes`, `no` or `abstain`. This is +the usual method to vote on a motion. + + +#### config + +`allow_abstain`: if set to `true`, users are allowed to vote with `abstain`. The +default is `true`. + +An approval poll can be used to vote on many options at once. For example to +approve a list of candidates. For polls like this, the optional arguments +`option_type` and `options` can be used. + +`option_type`: The type of the options. Can be `text` or +`meeting_user`. + +`options`: list of options. If option_type is `text`, the list-values have to be +from type string. If `option_type` is `meeting_user`, the values have to be +existing meeting_user_ids. + + +#### ballot/value + +Valid ballots look like: `{"value":"yes"}`, `{"value":"no"}` or +`{"value":"abstain"}`. + + +#### poll/result + +The poll result looks like: + +`{"yes": "32", "no": "20", "abstain": "10", "invalid": 2}` + +Attributes with a zero get discarded. + +The values are decimal values decoded as string. See [Vote +Weight](#vote-weight). + + +### selection + +On a selection poll, the users select one or many options from a list of +options. For example one candidate in a assignment-poll. + + +#### config + +`option_type` (required): The type of the options. Can be `text` or +`meeting_user`. + +`options` (required): list of options. If option_type is `text`, the list-values +have to be from type string. If `option_type` is `meeting_user`, the values have +to be existing meeting_user_ids. + +`max_options_amount`: The maximal amount of options a user can vote on. For +example, with a value of `1`, a user is only allowed to vote for one candidate. +The default is no limit. + +`min_options_amount`: The minimum amount of options, a user has to vote on. The +default is no limit. + +`allow_nota`: Allow `nota` votes, where the user can disapprove of all options. +The default is `false`. + + +#### ballot/value + +A ballot is a list of option ids. For example: `{"value":[1]}`. + +To abstain from a poll, an empty list can be delivered: `{"value":[]}` + +If `allow_nota` is set, then a user can vote with +[nota](https://en.wikipedia.org/wiki/None_of_the_above): `{"value":"nota"}`. This +means, that they disapprove all options. + + +#### poll/result + +A result can look like this: +`{"1":"40","2":"23","nota":"6","abstain":"7","invalid":3}` + +The keys of the json-object are option_ids as string. + +This means, that users with a combined vote-weight of 40 have voted for the +option 1, 23 for the option 2, 6 with the string `nota`, 7 with +an empty list and 3 with an invalid vote. + + +### rating-score + +A `rating-score` poll is similar to a `selection` poll, but the users can give +a numeric value to each option. For example give each candidate 3 votes. + + +#### config + +`option_type` (required), `options` (required), `max_options_amount` and +`min_options_amount`: Are the same as from a selection-poll. For example: + +```json { - "1":[42], - "2":[42], - "3":[42] + "option_type": "meeting_user", + "options": [23,42,77], + "max_options_amount": 2, + "min_options_amount":1 } ``` -`42` is the user ID of the user. If a delegated user has also voted, the user id -of that users will also be in the response. +`max_votes_per_option`: The maximal number for each option. The default is no +limit. +`max_vote_sum`: The maximal number of points, that can be shared between the +options. The default is no limit. -### Vote Count +`min_vote_sum`: The minimum number of points, that have to be shared between the +options. The default is no limit. -The vote count handler tells how many users have voted. It is an open connection -that first returns the data for every poll known by the vote service and then -sends updates when the data changes. -The vote service knows about all started and stopped votes until they are -cleared. When a poll get cleared, the hander sends `0` as an update. +#### ballot/value -The data is streamed in the json-line-format. That means, that every update is -returned with a newline at the end and does not contain any other newline. +A ballot is an object/dictionary from the `option_id` as string to the numeric +score. For example: `{"value":{"1":3, "2":1}}`. -Each line is a map from the poll-id (as string) to the number of votes. +An empty object means abstain: `{"value":{}}` -Example: +#### poll/result -``` -curl localhost:9013/internal/vote/vote_count -``` +A result can looks simular to a `selection`-result: +`{"1":"40","2":"23",abstain":"7","invalid":3}` -Response: -``` -{"5": 1004,"7": 203} -{"5:0} -{"7":204} -{"9:"1} -``` +### rating-approval + +`rating-approval` is similar to `rating-score`, but for each option, the user +can give a value like `"Yes"`, `"No"` or `"Abstain"`. + + +#### config + +`option_type` (required), `options` (required), `max_options_amount` and +`min_options_amount`: The same as for `selection` or `rating-score`. + +`allow_abstain`: The same as for `approval`. + + +#### ballot/value + +A ballot value looks like a combination between `rating-score` and `approval`: +`{"value":{"1":"yes","2":"abstain"}}`. + + +#### poll/result + +A `rating-approval` result looks like: +`{"1":{"yes":"5","no":"1"},"2":{"yes":"1","abstain":"6"},"invalid":1}` + +This means, that for the option with id `1`, there where 5 ballots with `Yes`, +one ballot with `No` and no `abstain`. For the option with id `2`, there where +one `Yes`, 6 `Abstain` and no `No`. There where one invalid ballots. + +A ballot is invalid, if one of its values is invalid. For example a ballot like +`{"value":{"1":"yes","2":"INVALID-VALUE"}}` is counted as invalid for both +candidates. + + +## Delegation + +A user can delegate his voice to another user. This is only possible in a +meeting, where `meeting/users_enable_vote_delegation` is set to true. + +The term `acting_user` means the user, that sends the request. The term +`represented_user` is the user, for whom the acting user sends the vote. + +If `meeting/users_forbid_delegator_to_vote` is set to true, then only the user, +where the voice was delegated can vote. If set to `false`, then the +represented_user keeps the permission to vote for himself. + + +## Vote Weight + +Every ballot has a weight. It is a decimal number. The default is `1.000000`. +When `meeting/users_enable_vote_weight` is set to `true`, this value can be +changed for each user. Each user has a default vote weight +(`user/default_vote_weight`), that can be changed for each meeting +(`meeting_user/vote_weight`). + +This weight is saved (`ballot/weight`) and taken into account when generating +the result. + +The weight is not a floating number, but a decimal number. JSON can not +represent decimal numbers, so they are represented as strings. This is also the +reason, that values in `poll/results` are represented as strings. + +This feature does not work on crypto votes, since the server does not know, +which decrypted ballot belongs to which user. + + +## Vote Split + +When `poll/allow_vote_split` is set to true, the users are allowed to split +there vote. They do so, by sending multiple ballots with a weight value, where +the sum of all weights has to be lower or equal then there allowed weight. +Normally 1. + +A splitted vote looks like: `{"value":{"0.3":"yes","0.7":"no"},"split":true}` + +The attribute `split` says, that vote-split is activated for that ballot, then, +the `value` attribute is an object/dictionary, where the keys are decimals and the +values the normal ballot values. + +To be valid, each ballot-part has to be valid. If one part is invalid, the hole +ballot is treated as invalid. + +Vote split is not possible for secret polls. + + +## Invalid Votes + +Normally, the service validates the vote requests from the users. So invalid +ballots in the database and therefore in the `poll/result` should not be +possible. + +When the field `poll/allow_invalid` is set to true, then the service skips the +validation and saves the ballot exactly, how the user has provided it. In this +case, a user, that wants to create a invalid ballot can use any (invalid) value +for it. + +On crypto votes, invalid ballots are allways allowed. The server can not read +the value and has to accept it. Invalid ballots also occur, when the value can +not be decrypted. + +When a poll has invalid votes, the amount gets written in the poll result. for +example: + +`{"invalid":1,"no":"1","yes":"2"}` +The value is an integer and not a decimal value decoded as string. It counts the +amount of invalid ballots and not the vote-weight. -## Configuration -The service is configurated with environment variables. See [all environment varialbes](environment.md). +## Configuration of the service -If VOTE_SINGLE_INSTANCE it uses the memory to save fast votes. If not, it uses redis. +The service is configured with environment variables. See [all environment +variables](environment.md). diff --git a/backend/backend.go b/backend/backend.go deleted file mode 100644 index d304f69b..00000000 --- a/backend/backend.go +++ /dev/null @@ -1,94 +0,0 @@ -package backend - -import ( - "context" - "fmt" - "strconv" - "strings" - - "github.com/OpenSlides/openslides-go/environment" - "github.com/OpenSlides/openslides-vote-service/backend/memory" - "github.com/OpenSlides/openslides-vote-service/backend/postgres" - "github.com/OpenSlides/openslides-vote-service/backend/redis" - "github.com/OpenSlides/openslides-vote-service/vote" -) - -var ( - envRedisHost = environment.NewVariable("CACHE_HOST", "localhost", "Host of the redis used for the fast backend.") - envRedisPort = environment.NewVariable("CACHE_PORT", "6379", "Port of the redis used for the fast backend.") - - envPostgresHost = environment.NewVariable("VOTE_DATABASE_HOST", "localhost", "Host of the postgres database used for long polls.") - envPostgresPort = environment.NewVariable("VOTE_DATABASE_PORT", "5432", "Port of the postgres database used for long polls.") - envPostgresUser = environment.NewVariable("VOTE_DATABASE_USER", "openslides", "Databasename of the postgres database used for long polls.") - envPostgresDatabase = environment.NewVariable("VOTE_DATABASE_NAME", "openslides", "Name of the database to save long running polls.") - envPostgresPasswordFile = environment.NewVariable("VOTE_DATABASE_PASSWORD_FILE", "/run/secrets/postgres_password", "Password of the postgres database used for long polls.") - - envSingleInstance = environment.NewVariable("VOTE_SINGLE_INSTANCE", "false", "More performance if the serice is not scalled horizontally.") -) - -// Build builds a fast and a long backends from the environment. -func Build(lookup environment.Environmenter) (fast, long func(context.Context) (vote.Backend, error), singleInstance bool, err error) { - // All environment variables have to be called in this function and not in a - // sub function. In other case they will not be included in the generated - // file environment.md. - - buildMemory := func(_ context.Context) (vote.Backend, error) { - return memory.New(), nil - } - - redisAddr := envRedisHost.Value(lookup) + ":" + envRedisPort.Value(lookup) - buildRedis := func(ctx context.Context) (vote.Backend, error) { - r := redis.New(redisAddr) - r.Wait(ctx) - if ctx.Err() != nil { - return nil, ctx.Err() - } - - return r, nil - } - - dbPassword, err := environment.ReadSecret(lookup, envPostgresPasswordFile) - if err != nil { - return nil, nil, false, fmt.Errorf("reading postgres password: %w", err) - } - - postgresAddr := fmt.Sprintf( - `user='%s' password='%s' host='%s' port='%s' dbname='%s'`, - encodePostgresConfig(envPostgresUser.Value(lookup)), - dbPassword, - encodePostgresConfig(envPostgresHost.Value(lookup)), - encodePostgresConfig(envPostgresPort.Value(lookup)), - encodePostgresConfig(envPostgresDatabase.Value(lookup)), - ) - - buildPostgres := func(ctx context.Context) (vote.Backend, error) { - p, err := postgres.New(ctx, postgresAddr) - if err != nil { - return nil, fmt.Errorf("creating postgres connection pool: %w", err) - } - - p.Wait(ctx) - if err := p.Migrate(ctx); err != nil { - return nil, fmt.Errorf("creating shema: %w", err) - } - return p, nil - } - - long = buildPostgres - fast = buildRedis - singleInstace, _ := strconv.ParseBool(envSingleInstance.Value(lookup)) - if singleInstace { - fast = buildMemory - } - - return fast, long, singleInstace, nil -} - -// encodePostgresConfig encodes a string to be used in the postgres key value style. -// -// See: https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING -func encodePostgresConfig(s string) string { - s = strings.ReplaceAll(s, `\`, `\\`) - s = strings.ReplaceAll(s, `'`, `\'`) - return s -} diff --git a/backend/memory/memory.go b/backend/memory/memory.go deleted file mode 100644 index 86c3fc1a..00000000 --- a/backend/memory/memory.go +++ /dev/null @@ -1,157 +0,0 @@ -// Package memory implements the vote.Backend interface. -// -// All data are saved in memory. -package memory - -import ( - "context" - "fmt" - "maps" - "slices" - "sort" - "sync" - "testing" -) - -const ( - pollStateUnknown = iota - pollStateStarted - pollStateStopped -) - -// Backend is a vote backend that holds the data in memory. -type Backend struct { - mu sync.Mutex - votes map[int]map[int][]byte - state map[int]int -} - -// New initializes a new memory.Backend. -func New() *Backend { - b := Backend{ - votes: make(map[int]map[int][]byte), - state: make(map[int]int), - } - return &b -} - -func (b *Backend) String() string { - return "memory" -} - -// Start opens opens a poll. -func (b *Backend) Start(ctx context.Context, pollID int) error { - b.mu.Lock() - defer b.mu.Unlock() - - if b.state[pollID] == pollStateStopped { - return nil - } - b.state[pollID] = pollStateStarted - return nil -} - -// Stop stopps a poll. -func (b *Backend) Stop(ctx context.Context, pollID int) ([][]byte, []int, error) { - b.mu.Lock() - defer b.mu.Unlock() - - if b.state[pollID] == pollStateUnknown { - return nil, nil, doesNotExistError{fmt.Errorf("Poll does not exist")} - } - - b.state[pollID] = pollStateStopped - - userIDs := slices.Collect(maps.Keys(b.votes[pollID])) - votes := slices.Collect(maps.Values(b.votes[pollID])) - sort.Ints(userIDs) - return votes, userIDs, nil -} - -// Vote saves a vote. -func (b *Backend) Vote(ctx context.Context, pollID int, userID int, vote []byte) error { - b.mu.Lock() - defer b.mu.Unlock() - - if b.state[pollID] == pollStateUnknown { - return doesNotExistError{fmt.Errorf("poll is not started")} - } - - if b.state[pollID] == pollStateStopped { - return stoppedError{fmt.Errorf("poll is stopped")} - } - - if b.votes[pollID] == nil { - b.votes[pollID] = make(map[int][]byte) - } - - if _, ok := b.votes[pollID][userID]; ok { - return doubleVoteError{fmt.Errorf("user has already voted")} - } - - b.votes[pollID][userID] = vote - return nil -} - -// Clear removes all data for a poll. -func (b *Backend) Clear(ctx context.Context, pollID int) error { - b.mu.Lock() - defer b.mu.Unlock() - - delete(b.votes, pollID) - delete(b.state, pollID) - return nil -} - -// ClearAll removes all data for all polls. -func (b *Backend) ClearAll(ctx context.Context) error { - b.mu.Lock() - defer b.mu.Unlock() - - b.votes = make(map[int]map[int][]byte) - b.state = make(map[int]int) - return nil -} - -// LiveVotes returns all votes from each user. Returns nil on non named votes. -func (b *Backend) LiveVotes(ctx context.Context) (map[int]map[int][]byte, error) { - b.mu.Lock() - defer b.mu.Unlock() - - out := make(map[int]map[int][]byte, len(b.votes)) - for pollID, userID2Vote := range b.votes { - out[pollID] = maps.Clone(userID2Vote) - } - - return out, nil -} - -// AssertUserHasVoted is a method for the tests to check, if a user has voted. -func (b *Backend) AssertUserHasVoted(t *testing.T, pollID, userID int) { - t.Helper() - - b.mu.Lock() - defer b.mu.Unlock() - - if _, ok := b.votes[pollID][userID]; !ok { - t.Errorf("User %d has not voted", userID) - } -} - -type doesNotExistError struct { - error -} - -func (doesNotExistError) DoesNotExist() {} - -type doubleVoteError struct { - error -} - -func (doubleVoteError) DoubleVote() {} - -type stoppedError struct { - error -} - -func (stoppedError) Stopped() {} diff --git a/backend/memory/memory_test.go b/backend/memory/memory_test.go deleted file mode 100644 index 6afa8550..00000000 --- a/backend/memory/memory_test.go +++ /dev/null @@ -1,14 +0,0 @@ -package memory_test - -import ( - "testing" - - "github.com/OpenSlides/openslides-vote-service/backend/memory" - "github.com/OpenSlides/openslides-vote-service/backend/test" -) - -func TestBackend(t *testing.T) { - m := memory.New() - - test.Backend(t, m) -} diff --git a/backend/postgres/postgres.go b/backend/postgres/postgres.go deleted file mode 100644 index ec5989a4..00000000 --- a/backend/postgres/postgres.go +++ /dev/null @@ -1,429 +0,0 @@ -package postgres - -import ( - "bytes" - "context" - _ "embed" // Needed for file embedding - "encoding/binary" - "errors" - "fmt" - "sort" - "time" - - "github.com/OpenSlides/openslides-vote-service/log" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" - "github.com/jackc/pgx/v5/pgxpool" -) - -//go:embed schema.sql -var schema string - -// Backend holds the state of the backend. -// -// Has to be initializes with New(). -type Backend struct { - pool *pgxpool.Pool -} - -// New creates a new connection pool. -func New(ctx context.Context, connString string) (*Backend, error) { - conf, err := pgxpool.ParseConfig(connString) - if err != nil { - return nil, fmt.Errorf("invalid connection url: %w", err) - } - - // Fix issue with gbBouncer. The documentation says, that this make the - // connection slower. We have to test the performance. Maybe it is better to - // remove the connection pool here or not use bgBouncer at all. - // - // See https://github.com/OpenSlides/openslides-vote-service/pull/66 - conf.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol - - pool, err := pgxpool.NewWithConfig(ctx, conf) - if err != nil { - return nil, fmt.Errorf("creating connection pool: %w", err) - } - - b := Backend{ - pool: pool, - } - - return &b, nil -} - -func (b *Backend) String() string { - return "postgres" -} - -// Wait blocks until a connection to postgres can be established. -func (b *Backend) Wait(ctx context.Context) { - for ctx.Err() == nil { - err := b.pool.Ping(ctx) - if err == nil { - return - } - log.Info("Waiting for postgres: %v", err) - time.Sleep(500 * time.Millisecond) - } -} - -// Migrate creates the database schema. -func (b *Backend) Migrate(ctx context.Context) error { - if _, err := b.pool.Exec(ctx, schema); err != nil { - return fmt.Errorf("creating schema: %w", err) - } - return nil -} - -// Close closes all connections. It blocks, until all connection are closed. -func (b *Backend) Close() { - b.pool.Close() -} - -// Start starts a poll. -func (b *Backend) Start(ctx context.Context, pollID int) error { - sql := `INSERT INTO vote.poll (id, stopped) VALUES ($1, false) ON CONFLICT DO NOTHING; - ` - log.Debug("SQL: `%s` (values: %d)", sql, pollID) - if _, err := b.pool.Exec(ctx, sql, pollID); err != nil { - return fmt.Errorf("insert poll: %w", err) - } - return nil -} - -// Vote adds a vote to a poll. -// -// If an transaction error happens, the vote is saved again. This is done until -// either the vote is saved or the given context is canceled. -func (b *Backend) Vote(ctx context.Context, pollID int, userID int, object []byte) error { - return continueOnTransactionError(ctx, func() error { - return b.voteOnce(ctx, pollID, userID, object) - }) -} - -// voteOnce tries to add the vote once. -func (b *Backend) voteOnce(ctx context.Context, pollID int, userID int, object []byte) (err error) { - log.Debug("SQL: Begin transaction for vote") - defer func() { - log.Debug("SQL: End transaction for vote with error: %v", err) - }() - - err = pgx.BeginTxFunc( - ctx, - b.pool, - pgx.TxOptions{ - IsoLevel: "REPEATABLE READ", - }, - func(tx pgx.Tx) error { - sql := `SELECT stopped, user_ids FROM vote.poll WHERE id = $1;` - log.Debug("SQL: `%s` (values: %d)", sql, pollID) - - var stopped bool - var uIDsRaw []byte - if err := tx.QueryRow(ctx, sql, pollID).Scan(&stopped, &uIDsRaw); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return doesNotExistError{fmt.Errorf("unknown poll")} - } - return fmt.Errorf("fetching poll data: %w", err) - } - - if stopped { - return stoppedError{fmt.Errorf("poll is stopped")} - } - - uIDs, err := userIDListFromBytes(uIDsRaw) - if err != nil { - return fmt.Errorf("parsing user ids: %w", err) - } - - if err := uIDs.add(int32(userID)); err != nil { - return fmt.Errorf("adding userID to voted users: %w", err) - } - - uIDsRaw, err = uIDs.toBytes() - if err != nil { - return fmt.Errorf("converting user ids to bytes: %w", err) - } - - sql = "UPDATE vote.poll SET user_ids = $1 WHERE id = $2;" - log.Debug("SQL: `%s` (values: [user_ids]), %d", sql, pollID) - if _, err := tx.Exec(ctx, sql, uIDsRaw, pollID); err != nil { - return fmt.Errorf("writing user ids: %w", err) - } - - sql = "INSERT INTO vote.objects (poll_id, vote) VALUES ($1, $2);" - log.Debug("SQL: `%s` (values: %d, [vote]", sql, pollID) - if _, err := tx.Exec(ctx, sql, pollID, object); err != nil { - return fmt.Errorf("writing vote: %w", err) - } - - return nil - }, - ) - if err != nil { - return fmt.Errorf("running transaction: %w", err) - } - return nil -} - -// Stop ends a poll and returns all vote objects and users who have voted. -// -// If an transaction error happens, the poll is stopped again. This is done -// until either the poll is stopped or the given context is canceled. -func (b *Backend) Stop(ctx context.Context, pollID int) ([][]byte, []int, error) { - var objs [][]byte - var userIDs []int - err := continueOnTransactionError(ctx, func() error { - o, uids, err := b.stopOnce(ctx, pollID) - if err != nil { - return err - } - objs = o - userIDs = uids - return nil - }) - - return objs, userIDs, err -} - -// stopOnce ends a poll and returns all vote objects. -func (b *Backend) stopOnce(ctx context.Context, pollID int) (objects [][]byte, users []int, err error) { - log.Debug("SQL: Begin transaction for vote") - defer func() { - log.Debug("SQL: End transaction for vote with error: %v", err) - }() - - err = pgx.BeginTxFunc( - ctx, - b.pool, - pgx.TxOptions{ - IsoLevel: "REPEATABLE READ", - }, - func(tx pgx.Tx) error { - sql := "SELECT EXISTS(SELECT 1 FROM vote.poll WHERE id = $1);" - log.Debug("SQL: `%s` (values: %d", sql, pollID) - - var exists bool - if err := tx.QueryRow(ctx, sql, pollID).Scan(&exists); err != nil { - return fmt.Errorf("fetching poll exists: %w", err) - } - - if !exists { - return doesNotExistError{fmt.Errorf("Poll does not exist")} - } - - sql = "UPDATE vote.poll SET stopped = true WHERE id = $1;" - if _, err := tx.Exec(ctx, sql, pollID); err != nil { - return fmt.Errorf("setting poll %d to stopped: %w", pollID, err) - } - - sql = ` - SELECT Obj.vote - FROM vote.poll Poll - LEFT JOIN vote.objects Obj ON Obj.poll_id = Poll.id - WHERE Poll.id = $1; - ` - log.Debug("SQL: `%s` (values: %d", sql, pollID) - rows, err := tx.Query(ctx, sql, pollID) - if err != nil { - return fmt.Errorf("fetching vote objects: %w", err) - } - - for rows.Next() { - var bs []byte - err = rows.Scan(&bs) - if err != nil { - return fmt.Errorf("parsind row: %w", err) - } - if len(bs) == 0 { - continue - } - objects = append(objects, bs) - } - - if err := rows.Err(); err != nil { - return fmt.Errorf("parsing query rows: %w", err) - } - - sql = ` - SELECT user_ids - FROM vote.poll - WHERE poll.id = $1; - ` - var rawUserIDs []byte - if err := tx.QueryRow(ctx, sql, pollID).Scan(&rawUserIDs); err != nil { - return fmt.Errorf("fetching poll data: %w", err) - } - - uIDs, err := userIDListFromBytes(rawUserIDs) - if err != nil { - return fmt.Errorf("parsing user ids: %w", err) - } - - for _, id := range uIDs { - users = append(users, int(id)) - } - - return nil - }, - ) - if err != nil { - return nil, nil, fmt.Errorf("running transaction: %w", err) - } - return objects, users, nil -} - -// Clear removes all data about a poll from the database. -func (b *Backend) Clear(ctx context.Context, pollID int) error { - sql := "DELETE FROM vote.poll WHERE id = $1" - log.Debug("SQL: `%s` (values: %d)", sql, pollID) - if _, err := b.pool.Exec(ctx, sql, pollID); err != nil { - return fmt.Errorf("deleting data of poll %d: %w", pollID, err) - } - return nil -} - -// ClearAll removes all vote related data from postgres. -// -// It does this by dropping vote vote-schema. If other services would write -// thinks in this schema or hava a relation to this schema, then this would also -// delete this tables. -// -// Since the schema is deleted and afterwards recreated this command can also be -// used, if the db-schema has changed. It is kind of a migration. -func (b *Backend) ClearAll(ctx context.Context) error { - sql := "DROP SCHEMA IF EXISTS vote CASCADE" - log.Debug("SQL: `%s`", sql) - if _, err := b.pool.Exec(ctx, sql); err != nil { - return fmt.Errorf("deleting vote schema: %w", err) - } - - if err := b.Migrate(ctx); err != nil { - return fmt.Errorf("recreate schema: %w", err) - } - return nil -} - -// LiveVotes returns all votes from each user. -// -// This this is impossible for the current implementation, it only returns nil -// for each vote. -func (b *Backend) LiveVotes(ctx context.Context) (map[int]map[int][]byte, error) { - sql := `SELECT id, user_ids FROM vote.poll;` - - log.Debug("SQL: `%s`", sql) - rows, err := b.pool.Query(ctx, sql) - if err != nil { - return nil, fmt.Errorf("fetching user_ids from all poll objects: %w", err) - } - - out := make(map[int]map[int][]byte) - for rows.Next() { - var pid int - var rawUIDs []byte - if err := rows.Scan(&pid, &rawUIDs); err != nil { - return nil, fmt.Errorf("parsing row: %w", err) - } - - uIDs, err := userIDListFromBytes(rawUIDs) - if err != nil { - return nil, fmt.Errorf("parsing user ids: %w", err) - } - - out[pid] = make(map[int][]byte, uIDs.len()) - for _, uid := range uIDs { - out[pid][int(uid)] = nil - } - } - - return out, nil -} - -// ContinueOnTransactionError runs the given many times until is does not return -// an transaction error. Also stopes, when the given context is canceled. -func continueOnTransactionError(ctx context.Context, f func() error) error { - var err error - for ctx.Err() == nil { - err = f() - if err == nil { - break - } - - var perr *pgconn.PgError - if !errors.As(err, &perr) { - break - } - - // The error code is returned if another vote has manipulated the vote - // users while this vote was saved. - if perr.Code != "40001" { - break - } - } - return err -} - -type userIDList []int32 - -func userIDListFromBytes(raw []byte) (userIDList, error) { - ints := make([]int32, len(raw)/4) - if err := binary.Read(bytes.NewReader(raw), binary.LittleEndian, &ints); err != nil { - return nil, fmt.Errorf("decoding user ids: %w", err) - } - return userIDList(ints), nil -} - -func (u userIDList) toBytes() ([]byte, error) { - buf := new(bytes.Buffer) - if err := binary.Write(buf, binary.LittleEndian, u); err != nil { - return nil, fmt.Errorf("encoding user id %v: %w", u, err) - } - - return buf.Bytes(), nil -} - -// add adds the userID to the userIDs -func (u *userIDList) add(userID int32) error { - // idx is either the index of userID or the place where it should be - // inserted. - ints := []int32(*u) - idx := sort.Search(len(ints), func(i int) bool { return ints[i] >= userID }) - if idx < len(ints) && ints[idx] == userID { - return doubleVoteError{fmt.Errorf("User has already voted")} - } - - // Insert the index at the correct order. - ints = append(ints[:idx], append([]int32{userID}, ints[idx:]...)...) - *u = ints - return nil -} - -// contains returns true if the userID is contains the list of userIDs. -func (u *userIDList) contains(userID int32) bool { - ints := []int32(*u) - idx := sort.Search(len(ints), func(i int) bool { return ints[i] >= userID }) - return idx < len(ints) && ints[idx] == userID -} - -func (u *userIDList) len() int { - return len([]int32(*u)) -} - -type doesNotExistError struct { - error -} - -func (doesNotExistError) DoesNotExist() {} - -type doubleVoteError struct { - error -} - -func (doubleVoteError) DoubleVote() {} - -type stoppedError struct { - error -} - -func (stoppedError) Stopped() {} diff --git a/backend/postgres/postgres_test.go b/backend/postgres/postgres_test.go deleted file mode 100644 index bc3f4339..00000000 --- a/backend/postgres/postgres_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package postgres_test - -import ( - "context" - "fmt" - "testing" - - "github.com/OpenSlides/openslides-vote-service/backend/postgres" - "github.com/OpenSlides/openslides-vote-service/backend/test" - "github.com/ory/dockertest/v3" -) - -func startPostgres(t *testing.T) (string, func()) { - t.Helper() - - pool, err := dockertest.NewPool("") - if err != nil { - t.Fatalf("Could not connect to docker: %s", err) - } - - runOpts := dockertest.RunOptions{ - Repository: "postgres", - Tag: "13", - Env: []string{ - "POSTGRES_USER=postgres", - "POSTGRES_PASSWORD=password", - "POSTGRES_DB=database", - }, - } - - resource, err := pool.RunWithOptions(&runOpts) - if err != nil { - t.Fatalf("Could not start postgres container: %s", err) - } - - return resource.GetPort("5432/tcp"), func() { - if err = pool.Purge(resource); err != nil { - t.Fatalf("Could not purge postgres container: %s", err) - } - } -} - -func TestImplementBackendInterface(t *testing.T) { - if testing.Short() { - t.Skip("Skip Postgres Test") - } - - ctx := context.Background() - port, close := startPostgres(t) - defer close() - - addr := fmt.Sprintf(`user=postgres password='password' host=localhost port=%s dbname=database`, port) - p, err := postgres.New(ctx, addr) - if err != nil { - t.Fatalf("Creating postgres backend returned: %v", err) - } - defer p.Close() - - p.Wait(ctx) - if err := p.Migrate(ctx); err != nil { - t.Fatalf("Creating db schema: %v", err) - } - - t.Logf("Postgres port: %s", port) - - test.Backend(t, p) -} diff --git a/backend/postgres/schema.sql b/backend/postgres/schema.sql deleted file mode 100644 index 2ef17777..00000000 --- a/backend/postgres/schema.sql +++ /dev/null @@ -1,21 +0,0 @@ -CREATE SCHEMA IF NOT EXISTS vote; - -CREATE TABLE IF NOT EXISTS vote.poll( - id INTEGER UNIQUE NOT NULL, - stopped BOOLEAN NOT NULL, - - -- user_ids is managed by the application. It stores all user ids in a way - -- that makes it impossible to see the sequence in which the users have - -- voted. - user_ids BYTEA -); - -CREATE TABLE IF NOT EXISTS vote.objects ( - id SERIAL PRIMARY KEY, - - -- There are many raws per poll. - poll_id INTEGER NOT NULL REFERENCES vote.poll(id) ON DELETE CASCADE, - - -- The vote object. - vote BYTEA -); diff --git a/backend/redis/redis.go b/backend/redis/redis.go deleted file mode 100644 index 1809be5d..00000000 --- a/backend/redis/redis.go +++ /dev/null @@ -1,305 +0,0 @@ -// Package redis implements a vote.Backend. -// -// Is tries to save the votes as fast as possible. All necessary checkes are -// done inside a lua-script so everything is done in one atomic step. It is -// expected that there is no backup from the redis database. Everyone with -// access to the redis database can see the vote results and how each user has -// voted. -// -// It uses the keys `vote_state_X`, `vote_data_X` and `vote_polls` where X is a -// pollID. -// -// The key `vote_state_X` has type int. It is a number that tells the current -// state of the poll. 1: Poll is started. 2: Poll is stopped. -// -// The key `vote_data_X` has type hash. The key is a user id and the value the -// vote of the user. -// -// The key `vote_polls` has type set. It contains the pollIDs of all known polls. -package redis - -import ( - "context" - "fmt" - "sort" - "strconv" - "strings" - "time" - - "github.com/OpenSlides/openslides-vote-service/log" - "github.com/gomodule/redigo/redis" -) - -const ( - keyState = "vote_state_%d" - keyVote = "vote_data_%d" - keyPolls = "vote_polls" -) - -// Backend is the vote-Backend. -// -// Has to be created with redis.New(). -type Backend struct { - pool *redis.Pool - - luaScriptVote *redis.Script - luaScriptClearAll *redis.Script -} - -// New creates an initializes Redis instance. -func New(addr string) *Backend { - pool := redis.Pool{ - MaxActive: 100, - Wait: true, - MaxIdle: 10, - IdleTimeout: 240 * time.Second, - Dial: func() (redis.Conn, error) { return redis.Dial("tcp", addr) }, - } - - return &Backend{ - pool: &pool, - - luaScriptVote: redis.NewScript(2, luaVoteScript), - luaScriptClearAll: redis.NewScript(1, luaClearAll), - } -} - -// Wait blocks until a connection to redis can be established. -func (b *Backend) Wait(ctx context.Context) { - for ctx.Err() == nil { - conn := b.pool.Get() - _, err := conn.Do("PING") - conn.Close() - if err == nil { - return - } - log.Info("Waiting for redis: %v", err) - time.Sleep(500 * time.Millisecond) - } -} - -func (b *Backend) String() string { - return "redis" -} - -// Start starts the poll. -func (b *Backend) Start(ctx context.Context, pollID int) error { - conn := b.pool.Get() - defer conn.Close() - - sKey := fmt.Sprintf(keyState, pollID) - - log.Debug("Redis: SETNX %s 1", sKey) - if _, err := conn.Do("SETNX", sKey, 1); err != nil { - return fmt.Errorf("set state key to 1: %w", err) - } - - log.Debug("Redis: SADD %s %d", keyPolls, pollID) - if _, err := conn.Do("SADD", keyPolls, pollID); err != nil { - return fmt.Errorf("add poll ID to %s: %w", keyPolls, err) - } - return nil -} - -// luaVoteScript checks for condition and saves a vote if all checks pass. -// -// KEYS[1] == state key -// KEYS[2] == vote data -// ARGV[1] == userID -// ARGV[2] == Vote object -// -// Returns 0 on success -// Returns 1 if the poll is not started. -// Returns 2 if the poll was stopped. -// Returns 3 if the user has already voted. -const luaVoteScript = ` -local state = redis.call("GET",KEYS[1]) -if state == false then - return 1 -end - -if state == "2" then - return 2 -end - -local saved = redis.call("HSETNX",KEYS[2],ARGV[1],ARGV[2]) -if saved == 0 then - return 3 -end - -return 0` - -// Vote saves a vote in redis. -// -// It also checks, that the user did not vote before and that the poll is open. -func (b *Backend) Vote(ctx context.Context, pollID int, userID int, object []byte) error { - conn := b.pool.Get() - defer conn.Close() - - vKey := fmt.Sprintf(keyVote, pollID) - sKey := fmt.Sprintf(keyState, pollID) - - log.Debug("Redis: lua script vote: '%s' 2 %s %s [userID] [vote]", luaVoteScript, sKey, vKey) - result, err := redis.Int(b.luaScriptVote.Do(conn, sKey, vKey, userID, object)) - if err != nil { - return fmt.Errorf("executing luaVoteScript: %w", err) - } - - log.Debug("Redis: Returned %d", result) - switch result { - case 1: - return doesNotExistError{fmt.Errorf("poll is not started")} - case 2: - return stoppedError{fmt.Errorf("poll is stopped")} - case 3: - return doubleVoteError{fmt.Errorf("user has voted")} - default: - return nil - } -} - -// Stop ends a poll. -// -// It returns all vote objects. -func (b *Backend) Stop(ctx context.Context, pollID int) ([][]byte, []int, error) { - conn := b.pool.Get() - defer conn.Close() - - vKey := fmt.Sprintf(keyVote, pollID) - sKey := fmt.Sprintf(keyState, pollID) - - log.Debug("SET %s 2 XX", sKey) - _, err := redis.String(conn.Do("SET", sKey, "2", "XX")) - if err != nil { - if err == redis.ErrNil { - return nil, nil, doesNotExistError{fmt.Errorf("poll does not exist")} - } - return nil, nil, fmt.Errorf("set key %s to 2: %w", sKey, err) - } - - log.Debug("REDIS: HGETALL %s", vKey) - data, err := redis.StringMap(conn.Do("HGETALL", vKey)) - if err != nil { - return nil, nil, fmt.Errorf("getting vote objects from %s: %w", vKey, err) - } - - userIDs := make([]int, 0, len(data)) - voteObjects := make([][]byte, 0, len(data)) - for uid, vote := range data { - id, err := strconv.Atoi(uid) - if err != nil { - return nil, nil, fmt.Errorf("invalid userID %s: %w", uid, err) - } - userIDs = append(userIDs, id) - voteObjects = append(voteObjects, []byte(vote)) - } - - sort.Ints(userIDs) - return voteObjects, userIDs, nil -} - -// Clear delete all information from a poll. -func (b *Backend) Clear(ctx context.Context, pollID int) error { - conn := b.pool.Get() - defer conn.Close() - - vKey := fmt.Sprintf(keyVote, pollID) - sKey := fmt.Sprintf(keyState, pollID) - - log.Debug("REDIS: DEL %s %s", vKey, sKey) - if _, err := conn.Do("DEL", vKey, sKey); err != nil { - return fmt.Errorf("removing keys: %w", err) - } - - log.Debug("REDIS: SREM %s %d", keyPolls, pollID) - if _, err := conn.Do("SREM", keyPolls, pollID); err != nil { - return fmt.Errorf("remove pollID from %s: %w", keyPolls, err) - } - - return nil -} - -// luaClearAll removes all vote related data from redis. -// -// KEYS[1] == polls -// -// ARGV[1] == state key pattern -// ARGV[2] == vote data pattern -const luaClearAll = ` -for _, pollID in ipairs(redis.call("SMEMBERS",KEYS[1])) do - redis.call("DEL", ARGV[1]..pollID) - redis.call("DEL", ARGV[2]..pollID) -end -redis.call("DEL", KEYS[1]) -` - -// ClearAll removes all data from all polls. -func (b *Backend) ClearAll(ctx context.Context) error { - conn := b.pool.Get() - defer conn.Close() - - voteKeyPattern := strings.ReplaceAll(keyVote, "%d", "") - stateKeyPattern := strings.ReplaceAll(keyState, "%d", "") - - log.Debug("Redis: lua script clear all: '%s' 2 %s %s", luaClearAll, voteKeyPattern, stateKeyPattern) - if _, err := b.luaScriptClearAll.Do(conn, keyPolls, voteKeyPattern, stateKeyPattern); err != nil { - return fmt.Errorf("removing keys: %w", err) - } - - return nil -} - -// LiveVotes returns all votes from each user. Returns nil on non named votes. -// -// This command is not atomic. -func (b *Backend) LiveVotes(ctx context.Context) (map[int]map[int][]byte, error) { - conn := b.pool.Get() - defer conn.Close() - - log.Debug("REDIS: SMEMBERS %s", keyPolls) - pollIDs, err := redis.Ints(conn.Do("SMEMBERS", keyPolls)) - if err != nil { - return nil, fmt.Errorf("getting all known pollIDs: %w", err) - } - - out := make(map[int]map[int][]byte, len(pollIDs)) - for _, pollID := range pollIDs { - vKey := fmt.Sprintf(keyVote, pollID) - - log.Debug("REDIS: HGETALL %s", vKey) - data, err := redis.StringMap(conn.Do("HGETALL", vKey)) - if err != nil { - return nil, fmt.Errorf("getting vote objects from %s: %w", vKey, err) - } - - out[pollID] = make(map[int][]byte, len(data)) - - for uidString, vote := range data { - userID, err := strconv.Atoi(uidString) - if err != nil { - return nil, fmt.Errorf("invalid userID %s: %w", uidString, err) - } - out[pollID][userID] = []byte(vote) - } - } - - return out, nil -} - -type doesNotExistError struct { - error -} - -func (doesNotExistError) DoesNotExist() {} - -type doubleVoteError struct { - error -} - -func (doubleVoteError) DoubleVote() {} - -type stoppedError struct { - error -} - -func (stoppedError) Stopped() {} diff --git a/backend/redis/redis_test.go b/backend/redis/redis_test.go deleted file mode 100644 index a32b27eb..00000000 --- a/backend/redis/redis_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package redis_test - -import ( - "context" - "testing" - - "github.com/OpenSlides/openslides-vote-service/backend/redis" - "github.com/OpenSlides/openslides-vote-service/backend/test" - "github.com/ory/dockertest/v3" -) - -func startRedis(t *testing.T) (string, func()) { - t.Helper() - - pool, err := dockertest.NewPool("") - if err != nil { - t.Fatalf("Could not connect to docker: %s", err) - } - - resource, err := pool.Run("redis", "6.2", nil) - if err != nil { - t.Fatalf("Could not start redis container: %s", err) - } - - return resource.GetPort("6379/tcp"), func() { - if err = pool.Purge(resource); err != nil { - t.Fatalf("Could not purge redis container: %s", err) - } - } -} - -func TestImplementBackendInterface(t *testing.T) { - if testing.Short() { - t.Skip("Skip Redis Test") - } - - port, close := startRedis(t) - defer close() - - r := redis.New("localhost:" + port) - r.Wait(context.Background()) - t.Logf("Redis port: %s", port) - - test.Backend(t, r) -} diff --git a/backend/test/test.go b/backend/test/test.go deleted file mode 100644 index 184739c1..00000000 --- a/backend/test/test.go +++ /dev/null @@ -1,441 +0,0 @@ -// Package test impelemts a test suit to check if a backend implements all rules -// of the vote.Backend interface. -package test - -import ( - "context" - "errors" - "reflect" - "runtime" - "sort" - "sync" - "testing" - - "github.com/OpenSlides/openslides-vote-service/vote" -) - -// Backend checks that a backend implements the vote.Backend interface. -func Backend(t *testing.T, backend vote.Backend) { - t.Helper() - ctx := context.Background() - - pollID := 1 - t.Run("Start", func(t *testing.T) { - t.Run("Start unknown poll", func(t *testing.T) { - if err := backend.Start(ctx, pollID); err != nil { - t.Errorf("Start an unknown poll returned error: %v", err) - } - }) - - t.Run("Start started poll", func(t *testing.T) { - backend.Start(ctx, pollID) - if err := backend.Start(ctx, pollID); err != nil { - t.Errorf("Start a started poll returned error: %v", err) - } - }) - - t.Run("Start a stopped poll", func(t *testing.T) { - if _, _, err := backend.Stop(ctx, pollID); err != nil { - t.Fatalf("Stop returned: %v", err) - } - - if err := backend.Start(ctx, pollID); err != nil { - t.Errorf("Start a stopped poll returned error: %v", err) - } - - err := backend.Vote(ctx, pollID, 5, []byte("my vote")) - var errStopped interface{ Stopped() } - if !errors.As(err, &errStopped) { - t.Errorf("The stopped poll has to be stopped after calling start. Vote returned error: %v", err) - } - }) - }) - - t.Run("Stop", func(t *testing.T) { - t.Run("poll unknown", func(t *testing.T) { - _, _, err := backend.Stop(ctx, 404) - - var errDoesNotExist interface{ DoesNotExist() } - if !errors.As(err, &errDoesNotExist) { - t.Fatalf("Stop a unknown poll has to return an error with a method DoesNotExist(), got: %v", err) - } - }) - - pollID++ - t.Run("empty poll", func(t *testing.T) { - if err := backend.Start(ctx, pollID); err != nil { - t.Fatalf("Start returned unexpected error: %v", err) - } - - data, users, err := backend.Stop(ctx, pollID) - if err != nil { - t.Fatalf("Stop returned unexpected error: %v", err) - } - - if len(data) != 0 || len(users) != 0 { - t.Errorf("Stop() returned (%q, %q), expected two empty lists", data, users) - } - }) - }) - - pollID++ - t.Run("Vote", func(t *testing.T) { - t.Run("on notstarted poll", func(t *testing.T) { - err := backend.Vote(ctx, pollID, 5, []byte("my vote")) - - var errDoesNotExist interface{ DoesNotExist() } - if !errors.As(err, &errDoesNotExist) { - t.Fatalf("Vote on a not started poll has to return an error with a method DoesNotExist(), got: %v", err) - } - }) - - t.Run("successfull", func(t *testing.T) { - backend.Start(ctx, pollID) - - if err := backend.Vote(ctx, pollID, 5, []byte("my vote")); err != nil { - t.Fatalf("Vote returned unexpected error: %v", err) - } - - data, userIDs, err := backend.Stop(ctx, pollID) - if err != nil { - t.Fatalf("Stop returned unexpected error: %v", err) - } - - if len(data) != 1 { - t.Fatalf("Found %d vote objects, expected 1", len(data)) - } - - if string(data[0]) != "my vote" { - t.Errorf("Found vote object `%s`, expected `my vote`", data[0]) - } - - if len(userIDs) != 1 { - t.Fatalf("Found %d user ids, expected 1", len(userIDs)) - } - - if userIDs[0] != 5 { - t.Errorf("Got userID %d, expected 5", userIDs[0]) - } - }) - - pollID++ - t.Run("two times", func(t *testing.T) { - backend.Start(ctx, pollID) - - if err := backend.Vote(ctx, pollID, 5, []byte("my vote")); err != nil { - t.Fatalf("Vote returned unexpected error: %v", err) - } - - err := backend.Vote(ctx, pollID, 5, []byte("my second vote")) - - if err == nil { - t.Fatalf("Second vote did not return an error") - } - - var errDoubleVote interface{ DoubleVote() } - if !errors.As(err, &errDoubleVote) { - t.Fatalf("Vote has to return a error with method DoubleVote. Got: %v", err) - } - }) - - pollID++ - t.Run("on stopped vote", func(t *testing.T) { - backend.Start(ctx, pollID) - - if _, _, err := backend.Stop(ctx, pollID); err != nil { - t.Fatalf("Stop returned unexpected error: %v", err) - } - - err := backend.Vote(ctx, pollID, 5, []byte("my vote")) - - if err == nil { - t.Fatalf("Vote on stopped poll did not return an error") - } - - var errStopped interface{ Stopped() } - if !errors.As(err, &errStopped) { - t.Fatalf("Vote has to return a error with method Stopped. Got: %v", err) - } - }) - }) - - pollID++ - t.Run("Clear removes vote data", func(t *testing.T) { - backend.Start(ctx, pollID) - backend.Vote(ctx, pollID, 5, []byte("my vote")) - - if err := backend.Clear(ctx, pollID); err != nil { - t.Fatalf("Clear returned unexpected error: %v", err) - } - - bs, userIDs, err := backend.Stop(ctx, pollID) - var errDoesNotExist interface{ DoesNotExist() } - if !errors.As(err, &errDoesNotExist) { - t.Fatalf("Stop a cleared poll has to return an error with a method DoesNotExist(), got: %v", err) - } - - if len(bs) != 0 { - t.Fatalf("Stop after clear returned unexpected data: %v", bs) - } - - if len(userIDs) != 0 { - t.Errorf("Stop after clear returned userIDs: %v", userIDs) - } - }) - - pollID++ - t.Run("Clear removes voted users", func(t *testing.T) { - backend.Start(ctx, pollID) - backend.Vote(ctx, pollID, 5, []byte("my vote")) - - if err := backend.Clear(ctx, pollID); err != nil { - t.Fatalf("Clear returned unexpected error: %v", err) - } - - backend.Start(ctx, pollID) - - // Vote on the same poll with the same user id - if err := backend.Vote(ctx, pollID, 5, []byte("my vote")); err != nil { - t.Fatalf("Vote after clear returned unexpected error: %v", err) - } - }) - - pollID++ - t.Run("ClearAll removes vote data", func(t *testing.T) { - backend.Start(ctx, pollID) - backend.Vote(ctx, pollID, 5, []byte("my vote")) - - if err := backend.ClearAll(ctx); err != nil { - t.Fatalf("ClearAll returned unexpected error: %v", err) - } - - bs, userIDs, err := backend.Stop(ctx, pollID) - var errDoesNotExist interface{ DoesNotExist() } - if !errors.As(err, &errDoesNotExist) { - t.Fatalf("Stop after clearAll has to return an error with a method DoesNotExist(), got: %v", err) - } - - if len(bs) != 0 { - t.Fatalf("Stop after clearAll returned unexpected data: %v", bs) - } - - if len(userIDs) != 0 { - t.Errorf("Stop after clearAll returned userIDs: %v", userIDs) - } - }) - - pollID++ - t.Run("ClearAll removes voted users", func(t *testing.T) { - backend.Start(ctx, pollID) - backend.Vote(ctx, pollID, 5, []byte("my vote")) - - if err := backend.ClearAll(ctx); err != nil { - t.Fatalf("ClearAll returned unexpected error: %v", err) - } - - if err := backend.Start(ctx, pollID); err != nil { - t.Fatalf("Start after clearAll returned unexpected error: %v", err) - } - - // Vote on the same poll with the same user id - if err := backend.Vote(ctx, pollID, 5, []byte("my vote")); err != nil { - t.Fatalf("Vote after clearAll returned unexpected error: %v", err) - } - }) - - backend.ClearAll(ctx) - pollID++ - t.Run("LiveVotes", func(t *testing.T) { - backend.Start(ctx, pollID) - backend.Vote(ctx, pollID, 5, []byte("my vote")) - - got, err := backend.LiveVotes(ctx) - if err != nil { - t.Fatalf("Voted returned unexpected error: %v", err) - } - - normalizeLiveVotes(got) - - expect := map[int]map[int][]byte{pollID: {5: nil}} - if !reflect.DeepEqual(got, expect) { - t.Errorf("Voted returned %v, expected %v", got, expect) - } - }) - - backend.ClearAll(ctx) - pollID++ - t.Run("Voted for many users", func(t *testing.T) { - backend.Start(ctx, pollID) - backend.Vote(ctx, pollID, 5, []byte("my vote")) - backend.Vote(ctx, pollID, 6, []byte("my vote")) - - got, err := backend.LiveVotes(ctx) - if err != nil { - t.Fatalf("Voted returned unexpected error: %v", err) - } - - normalizeLiveVotes(got) - - expect := map[int]map[int][]byte{pollID: {5: nil, 6: nil}} - if !reflect.DeepEqual(got, expect) { - t.Errorf("Voted returned %v, expected %v", got, expect) - } - }) - - pollID++ - t.Run("Concurrency", func(t *testing.T) { - t.Run("Many Votes", func(t *testing.T) { - count := 100 - backend.Start(ctx, pollID) - - var wg sync.WaitGroup - for i := 0; i < count; i++ { - wg.Add(1) - go func(uid int) { - defer wg.Done() - - if err := backend.Vote(ctx, pollID, uid, []byte("vote")); err != nil { - t.Errorf("Vote %d returned undexpected error: %v", uid, err) - } - }(i + 1) - } - wg.Wait() - - data, userIDs, err := backend.Stop(ctx, pollID) - if err != nil { - t.Fatalf("Stop returned unexpected error: %v", err) - } - - if len(data) != count { - t.Fatalf("Found %d vote objects, expected %d", len(data), count) - } - - if len(userIDs) != count { - t.Fatalf("Found %d userIDs, expected %d", len(userIDs), count) - } - - sort.Ints(userIDs) - for i := 0; i < count; i++ { - if userIDs[i] != i+1 { - t.Fatalf("Found user id %d on place %d, expected %d", userIDs[i], i, i+1) - } - } - }) - - pollID++ - t.Run("Many starts and stops", func(t *testing.T) { - starts := 50 - stops := 50 - - var wg sync.WaitGroup - for i := 0; i < starts; i++ { - wg.Add(1) - go func() { - defer wg.Done() - - if err := backend.Start(ctx, pollID); err != nil { - t.Errorf("Start returned undexpected error: %v", err) - } - }() - } - - for i := 0; i < stops; i++ { - wg.Add(1) - go func() { - defer wg.Done() - - if _, _, err := backend.Stop(ctx, pollID); err != nil { - var errDoesNotExist interface{ DoesNotExist() } - if errors.As(err, &errDoesNotExist) { - // Does not exist errors are expected - return - } - t.Errorf("Stop returned undexpected error: %v", err) - } - }() - } - wg.Wait() - }) - - pollID++ - t.Run("Many Stops and Votes", func(t *testing.T) { - stopsCount := 50 - votesCount := 50 - - backend.Start(ctx, pollID) - - expectedObjects := make([][][]byte, stopsCount) - expectedUserIDs := make([][]int, stopsCount) - var stoppedErrsMu sync.Mutex - var stoppedErrs int - - var wg sync.WaitGroup - for i := 0; i < votesCount; i++ { - wg.Add(1) - go func(uid int) { - defer wg.Done() - - err := backend.Vote(ctx, pollID, uid, []byte("vote")) - if err != nil { - var errStopped interface{ Stopped() } - if errors.As(err, &errStopped) { - // Stopped errors are expected. - stoppedErrsMu.Lock() - stoppedErrs++ - stoppedErrsMu.Unlock() - return - } - - t.Errorf("Vote %d returned undexpected error: %v", uid, err) - } - }(i + 1) - } - - // Let the other goroutines run. - runtime.Gosched() - - for i := 0; i < stopsCount; i++ { - wg.Add(1) - go func(i int) { - defer wg.Done() - - obj, userIDs, err := backend.Stop(ctx, pollID) - if err != nil { - t.Errorf("Stop returned undexpected error: %v", err) - return - } - expectedObjects[i] = obj - expectedUserIDs[i] = userIDs - }(i) - } - wg.Wait() - - expectedVotes := votesCount - stoppedErrs - - for _, objs := range expectedObjects { - if len(objs) != expectedVotes { - t.Errorf("Stop returned %d objects, expected %d: %v", len(objs), expectedVotes, objs) - } - } - - for _, userIDs := range expectedUserIDs { - if len(userIDs) != expectedVotes { - t.Errorf("Stop returned %d userIDs, expected %d", len(userIDs), expectedVotes) - } - } - }) - }) -} - -// normalizeLiveVotes removes the vote from the data returned from -// backend.LiveVotes. -// -// The postgres backend can not return a value and the current test-cases do not -// need it. -func normalizeLiveVotes(in map[int]map[int][]byte) { - for _, m := range in { - for k := range m { - m[k] = nil - } - } -} diff --git a/dev/container-tests.sh b/dev/container-tests.sh deleted file mode 100644 index 98c7113f..00000000 --- a/dev/container-tests.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/sh - -dockerd --storage-driver=vfs --log-level=error & - -# Close Dockerd savely on exit -DOCKERD_PID=$! -trap 'kill $DOCKERD_PID' EXIT INT TERM ERR - -RETRY=0 -MAX=10 -until docker info >/dev/null 2>&1; do - if [ "$RETRY" -ge "$MAX" ] - then - echo "Dockerd setup error" - exit 1 - fi - sleep 1 - RETRY=$((RETRY + 1)) - echo "Waiting for dockerd $RETRY/$MAX" -done - -echo "Started dockerd" - -# Run Linters & Tests -go vet ./... -go test -timeout 60s -race ./... -gofmt -l . -golint -set_exit_status ./... diff --git a/environment.md b/environment.md index cbf2964d..a4890671 100644 --- a/environment.md +++ b/environment.md @@ -5,7 +5,7 @@ The Service uses the following environment variables: -* `VOTE_PORT`: Port on which the service listen on. The default is `9013`. +* `VOTE_PORT`: Port on which the service listens on. The default is `9013`. * `MESSAGE_BUS_HOST`: Host of the redis server. The default is `localhost`. * `MESSAGE_BUS_PORT`: Port of the redis server. The default is `6379`. * `OPENSLIDES_DEVELOPMENT`: If set, the service uses the default secrets. The default is `false`. @@ -20,11 +20,3 @@ The Service uses the following environment variables: * `AUTH_FAKE`: Use user id 1 for every request. Ignores all other auth environment variables. The default is `false`. * `AUTH_TOKEN_KEY_FILE`: Key to sign the JWT auth tocken. The default is `/run/secrets/auth_token_key`. * `AUTH_COOKIE_KEY_FILE`: Key to sign the JWT auth cookie. The default is `/run/secrets/auth_cookie_key`. -* `CACHE_HOST`: Host of the redis used for the fast backend. The default is `localhost`. -* `CACHE_PORT`: Port of the redis used for the fast backend. The default is `6379`. -* `VOTE_DATABASE_PASSWORD_FILE`: Password of the postgres database used for long polls. The default is `/run/secrets/postgres_password`. -* `VOTE_DATABASE_USER`: Databasename of the postgres database used for long polls. The default is `openslides`. -* `VOTE_DATABASE_HOST`: Host of the postgres database used for long polls. The default is `localhost`. -* `VOTE_DATABASE_PORT`: Port of the postgres database used for long polls. The default is `5432`. -* `VOTE_DATABASE_NAME`: Name of the database to save long running polls. The default is `openslides`. -* `VOTE_SINGLE_INSTANCE`: More performance if the serice is not scalled horizontally. The default is `false`. diff --git a/go.mod b/go.mod index 8730802b..5a78575a 100644 --- a/go.mod +++ b/go.mod @@ -3,39 +3,38 @@ module github.com/OpenSlides/openslides-vote-service go 1.25.0 require ( - github.com/OpenSlides/openslides-go v0.0.0-20251017152006-0568d8b1bf90 + github.com/OpenSlides/openslides-go v0.0.0-20251104165549-d01b94f7d4cb github.com/alecthomas/kong v1.12.1 - github.com/gomodule/redigo v1.9.3 github.com/jackc/pgx/v5 v5.7.6 - github.com/ory/dockertest/v3 v3.12.0 github.com/shopspring/decimal v1.4.0 ) require ( - dario.cat/mergo v1.0.1 // indirect + dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/containerd/continuity v0.4.5 // indirect - github.com/docker/cli v28.0.4+incompatible // indirect - github.com/docker/docker v28.0.4+incompatible // indirect - github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/cli v28.4.0+incompatible // indirect + github.com/docker/docker v28.4.0+incompatible // indirect + github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-yaml v1.18.0 // indirect - github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/gomodule/redigo v1.9.3 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect - github.com/moby/sys/user v0.3.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/opencontainers/runc v1.2.6 // indirect + github.com/opencontainers/runc v1.3.1 // indirect + github.com/ory/dockertest/v3 v3.12.0 // indirect github.com/ostcar/topic v0.4.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/sirupsen/logrus v1.9.3 // indirect diff --git a/go.sum b/go.sum index 543e72d3..e4549876 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= -dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= @@ -8,8 +8,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= -github.com/OpenSlides/openslides-go v0.0.0-20251017152006-0568d8b1bf90 h1:3wJkn9bWDt9SXZlUBPYX7+EPt3B4xrqVt1lKlvutx0I= -github.com/OpenSlides/openslides-go v0.0.0-20251017152006-0568d8b1bf90/go.mod h1:Em6jcRrIaNDy6pkWLJx5gLLFO61th+GNQCmD/0AQPtY= +github.com/OpenSlides/openslides-go v0.0.0-20251104165549-d01b94f7d4cb h1:HUPCzYqUgjXav8P6xkRlRSMR8hpB6jV7+a+A85+t/Ek= +github.com/OpenSlides/openslides-go v0.0.0-20251104165549-d01b94f7d4cb/go.mod h1:QLyFHOBMXQzgAsU2A7cCx6619xpIroz++pwvk1H+yFs= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/kong v1.12.1 h1:iq6aMJDcFYP9uFrLdsiZQ2ZMmcshduyGv4Pek0MQPW0= @@ -25,12 +25,12 @@ github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/docker/cli v28.0.4+incompatible h1:pBJSJeNd9QeIWPjRcV91RVJihd/TXB77q1ef64XEu4A= -github.com/docker/cli v28.0.4+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/docker v28.0.4+incompatible h1:JNNkBctYKurkw6FrHfKqY0nKIDf5nrbxjVBtS+cdcok= -github.com/docker/docker v28.0.4+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= -github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/cli v28.4.0+incompatible h1:RBcf3Kjw2pMtwui5V0DIMdyeab8glEw5QY0UUU4C9kY= +github.com/docker/cli v28.4.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/docker v28.4.0+incompatible h1:KVC7bz5zJY/4AZe/78BIvCnPsLaC9T/zh72xnlrTTOk= +github.com/docker/docker v28.4.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= @@ -39,8 +39,6 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/gomodule/redigo v1.9.3 h1:dNPSXeXv6HCq2jdyWfjgmhBdqnR6PRO3m/G05nvpPC8= @@ -59,8 +57,6 @@ github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -69,16 +65,16 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= -github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/opencontainers/runc v1.2.6 h1:P7Hqg40bsMvQGCS4S7DJYhUZOISMLJOB2iGX5COWiPk= -github.com/opencontainers/runc v1.2.6/go.mod h1:dOQeFo29xZKBNeRBI0B19mJtfHv68YgCTh1X+YphA+4= +github.com/opencontainers/runc v1.3.1 h1:c/yY0oh2wK7tzDuD56REnSxyU8ubh8hoAIOLGLrm4SM= +github.com/opencontainers/runc v1.3.1/go.mod h1:9wbWt42gV+KRxKRVVugNP6D5+PQciRbenB4fLVsqGPs= github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCyRCw= github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE= github.com/ostcar/topic v0.4.1 h1:ORxFOS8BAVKRaeAr3lwYrETQAuKojCUxzWOoBn0CQTw= @@ -105,43 +101,16 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/log/log.go b/log/log.go deleted file mode 100644 index fe9adf29..00000000 --- a/log/log.go +++ /dev/null @@ -1,62 +0,0 @@ -package log - -import ( - "log" - "sync" -) - -var ( - loggerMu sync.RWMutex - debugLogger *log.Logger - infoLogger *log.Logger -) - -// SetDebugLogger sets the debug logger. The default is no log at all. -// -// This function should only be started at the beginnen of the program before -// the Debug was called for the frist time. -func SetDebugLogger(l *log.Logger) { - loggerMu.Lock() - defer loggerMu.Unlock() - debugLogger = l -} - -// SetInfoLogger sets the Logger for info messages. The default is log.Default() -// -// This function should only be started at the beginnen of the program before -// the Debug was called for the frist time. -func SetInfoLogger(l *log.Logger) { - loggerMu.Lock() - defer loggerMu.Unlock() - infoLogger = l -} - -// Info prints output that is important for the user. -func Info(format string, a ...interface{}) { - loggerMu.RLock() - defer loggerMu.RUnlock() - - if infoLogger == nil { - return - } - infoLogger.Printf(format, a...) -} - -// Debug prints output that is important for development and debugging. -// -// If EnableDebug() was not called, this function is a noop. -func Debug(format string, a ...interface{}) { - loggerMu.RLock() - defer loggerMu.RUnlock() - - if debugLogger == nil { - return - } - - debugLogger.Printf(format, a...) -} - -// IsDebug returns if debug output is enabled. -func IsDebug() bool { - return debugLogger != nil -} diff --git a/main.go b/main.go index 8ef492e9..80436d2b 100644 --- a/main.go +++ b/main.go @@ -4,22 +4,16 @@ import ( "context" "errors" "fmt" - golog "log" "os" - "strconv" "github.com/OpenSlides/openslides-go/auth" "github.com/OpenSlides/openslides-go/environment" messageBusRedis "github.com/OpenSlides/openslides-go/redis" - "github.com/OpenSlides/openslides-vote-service/backend" - "github.com/OpenSlides/openslides-vote-service/log" "github.com/OpenSlides/openslides-vote-service/vote" "github.com/OpenSlides/openslides-vote-service/vote/http" "github.com/alecthomas/kong" ) -var envDebugLog = environment.NewVariable("VOTE_DEBUG_LOG", "false", "Show debug log.") - //go:generate sh -c "go run main.go build-doc > environment.md" var cli struct { @@ -36,7 +30,6 @@ var cli struct { func main() { ctx, cancel := environment.InterruptContext() defer cancel() - log.SetInfoLogger(golog.Default()) kongCTX := kong.Parse(&cli, kong.UsageOnError()) switch kongCTX.Command() { @@ -63,13 +56,9 @@ func main() { func run(ctx context.Context) error { lookup := new(environment.ForProduction) - if debug, _ := strconv.ParseBool(envDebugLog.Value(lookup)); debug { - log.SetDebugLogger(golog.Default()) - } - service, err := initService(lookup) if err != nil { - return fmt.Errorf("init services: %w", err) + return fmt.Errorf("init service: %w", err) } return service(ctx) @@ -97,13 +86,13 @@ func buildDocu() error { func initService(lookup environment.Environmenter) (func(context.Context) error, error) { var backgroundTasks []func(context.Context, func(error)) - httpServer := http.New(lookup) + httpServer := http.New(lookup, fmt.Printf) // Redis as message bus for datastore and logout events. messageBus := messageBusRedis.New(lookup) // Datastore Service. - database, err := vote.Flow(lookup, messageBus) + database, dbPool, err := vote.Flow(lookup) if err != nil { return nil, fmt.Errorf("init database: %w", err) } @@ -115,23 +104,8 @@ func initService(lookup environment.Environmenter) (func(context.Context) error, } backgroundTasks = append(backgroundTasks, authBackground) - fastBackendStarter, longBackendStarter, singleInstance, err := backend.Build(lookup) - if err != nil { - return nil, fmt.Errorf("init vote backend: %w", err) - } - service := func(ctx context.Context) error { - fastBackend, err := fastBackendStarter(ctx) - if err != nil { - return fmt.Errorf("start fast backend: %w", err) - } - - longBackend, err := longBackendStarter(ctx) - if err != nil { - return fmt.Errorf("start long backend: %w", err) - } - - voteService, voteBackground, err := vote.New(ctx, fastBackend, longBackend, database, singleInstance) + voteService, voteBackground, err := vote.New(ctx, database, dbPool) if err != nil { return fmt.Errorf("starting service: %w", err) } @@ -163,5 +137,5 @@ func handleError(err error) { return } - log.Info("Error: %v", err) + fmt.Printf("Error: %v\n", err) } diff --git a/system_test/docker-compose.yml b/system_test/docker-compose.yml deleted file mode 100644 index 0c7a3b7b..00000000 --- a/system_test/docker-compose.yml +++ /dev/null @@ -1,41 +0,0 @@ -version: "3" -services: - postgres: - image: postgres:15 - environment: - - POSTGRES_USER=openslides - - POSTGRES_PASSWORD=openslides - - POSTGRES_DB=openslides - ports: - - 127.0.0.1:5432:5432 - - redis: - image: redis:alpine - command: redis-server --save "" - - vote: - build: - context: .. - environment: - - MESSAGE_BUS_HOST=redis - - CACHE_HOST=redis - - DATABASE_HOST=postgres - - VOTE_REDIS_HOST=redis - - VOTE_DATABASE_HOST=postgres - - VOTE_DEBUG_LOG=1 - - secrets: - - postgres_password - - auth_token_key - - auth_cookie_key - - ports: - - 127.0.0.1:9013:9013 - -secrets: - auth_token_key: - file: ./secrets/auth_token_key - auth_cookie_key: - file: ./secrets/auth_cookie_key - postgres_password: - file: ./secrets/postgres_password diff --git a/system_test/postgres_test.go b/system_test/postgres_test.go deleted file mode 100644 index b6eee635..00000000 --- a/system_test/postgres_test.go +++ /dev/null @@ -1,152 +0,0 @@ -package system_test - -import ( - "context" - "encoding/json" - "fmt" - "log" - "time" - - "github.com/OpenSlides/openslides-go/datastore/dskey" - "github.com/jackc/pgx/v5" -) - -const ( - dbUser = "openslides" - dbPassword = "openslides" - dbHost = "localhost" - dbPort = "5432" - dbName = "openslides" -) - -type postgresTestData struct { - pgxConfig *pgx.ConnConfig -} - -func newPostgresTestData(ctx context.Context) (p *postgresTestData, err error) { - addr := fmt.Sprintf( - `user=%s password='%s' host=%s port=%s dbname=%s`, - dbUser, - dbPassword, - dbHost, - dbPort, - dbName, - ) - config, err := pgx.ParseConfig(addr) - if err != nil { - return nil, fmt.Errorf("parse config: %w", err) - } - - ptd := postgresTestData{ - pgxConfig: config, - } - - defer func() { - if err != nil { - if err := ptd.Close(ctx); err != nil { - log.Printf("Closing postgres: %v", err) - } - } - }() - - if err := ptd.addSchema(ctx); err != nil { - return nil, fmt.Errorf("add schema: %w", err) - } - - return &ptd, nil -} - -func (p *postgresTestData) Close(ctx context.Context) error { - if err := p.dropData(ctx); err != nil { - return fmt.Errorf("remove old data: %w", err) - } - - return nil -} - -func (p *postgresTestData) conn(ctx context.Context) (*pgx.Conn, error) { - var conn *pgx.Conn - - for { - var err error - if p == nil { - return nil, fmt.Errorf("some error") - } - conn, err = pgx.ConnectConfig(ctx, p.pgxConfig) - if err == nil { - return conn, nil - } - - select { - case <-time.After(200 * time.Millisecond): - case <-ctx.Done(): - return nil, ctx.Err() - } - } -} - -func (p *postgresTestData) addSchema(ctx context.Context) error { - // Schema from datastore-repo - schema := ` - CREATE TABLE IF NOT EXISTS models ( - fqid VARCHAR(48) PRIMARY KEY, - data JSONB NOT NULL, - deleted BOOLEAN NOT NULL - );` - conn, err := p.conn(ctx) - if err != nil { - return fmt.Errorf("creating connection: %w", err) - } - defer conn.Close(ctx) - - if _, err := conn.Exec(ctx, schema); err != nil { - return fmt.Errorf("adding schema: %w", err) - } - return nil -} - -func (p *postgresTestData) addTestData(ctx context.Context, data map[dskey.Key][]byte) error { - objects := make(map[string]map[string]json.RawMessage) - for k, v := range data { - fqid := k.FQID() - if _, ok := objects[fqid]; !ok { - objects[fqid] = make(map[string]json.RawMessage) - } - objects[fqid][k.Field()] = v - } - - conn, err := p.conn(ctx) - if err != nil { - return fmt.Errorf("creating connection: %w", err) - } - defer conn.Close(ctx) - - for fqid, data := range objects { - encoded, err := json.Marshal(data) - if err != nil { - return fmt.Errorf("encode %v: %v", data, err) - } - - sql := fmt.Sprintf(`INSERT INTO models (fqid, data, deleted) VALUES ('%s', '%s', false);`, fqid, encoded) - if _, err := conn.Exec(ctx, sql); err != nil { - return fmt.Errorf("executing psql `%s`: %w", sql, err) - } - } - - return nil -} - -func (p *postgresTestData) dropData(ctx context.Context) error { - conn, err := p.conn(ctx) - if err != nil { - return fmt.Errorf("creating connection: %w", err) - } - defer conn.Close(ctx) - - sql := `TRUNCATE models;` - if _, err := conn.Exec(ctx, sql); err != nil { - return fmt.Errorf("executing psql `%s`: %w", sql, err) - } - - return nil -} diff --git a/system_test/secrets/auth_cookie_key b/system_test/secrets/auth_cookie_key deleted file mode 100644 index 8795d5bc..00000000 --- a/system_test/secrets/auth_cookie_key +++ /dev/null @@ -1 +0,0 @@ -openslides \ No newline at end of file diff --git a/system_test/secrets/auth_token_key b/system_test/secrets/auth_token_key deleted file mode 100644 index 8795d5bc..00000000 --- a/system_test/secrets/auth_token_key +++ /dev/null @@ -1 +0,0 @@ -openslides \ No newline at end of file diff --git a/system_test/secrets/postgres_password b/system_test/secrets/postgres_password deleted file mode 100644 index 8795d5bc..00000000 --- a/system_test/secrets/postgres_password +++ /dev/null @@ -1 +0,0 @@ -openslides \ No newline at end of file diff --git a/system_test/system_test.go b/system_test/system_test.go deleted file mode 100644 index 4ff47e3e..00000000 --- a/system_test/system_test.go +++ /dev/null @@ -1,229 +0,0 @@ -package system_test - -import ( - "context" - "fmt" - "io" - "net/http" - "os" - "strings" - "testing" - - "github.com/OpenSlides/openslides-go/auth/authtest" - "github.com/OpenSlides/openslides-go/datastore/dsmock" -) - -const ( - addr = "http://localhost:9013" -) - -func TestHealth(t *testing.T) { - skip(t) - - req, err := http.NewRequest("GET", addr+"/system/vote/health", nil) - if err != nil { - t.Fatalf("create request: %v", err) - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("send request: %v", err) - } - - if resp.StatusCode != 200 { - t.Errorf("Health request returned status %s", resp.Status) - } -} - -func TestStartVoteStopClear(t *testing.T) { - skip(t) - ctx := context.Background() - - db, err := newPostgresTestData(ctx) - if err != nil { - t.Fatalf("Create test DB: %v", err) - } - defer db.Close(ctx) - defer func() { - if err := clearVoteService(); err != nil { - t.Fatalf("clear vote service: %v", err) - } - }() - - if err := startPoll(ctx, db, 1); err != nil { - t.Fatalf("Start poll: %v", err) - } - - if err := vote(1, 1, strings.NewReader(`{"value":"Y"}`)); err != nil { - t.Fatalf("Vote: %v", err) - } - - stopBody, err := stopPoll(1) - if err != nil { - t.Fatalf("Stop poll: %v", err) - } - - expectBody := `{"votes":[{"request_user_id":1,"vote_user_id":1,"value":"Y","weight":"1.000000"}],"user_ids":[1]}` - if strings.TrimSpace(string(stopBody)) != expectBody { - t.Fatalf("Got != expect\n%s\n%s", stopBody, expectBody) - } - - if err := clearPoll(1); err != nil { - t.Fatalf("Clear poll: %v", err) - } -} - -func clearVoteService() error { - req, err := http.NewRequest("GET", fmt.Sprintf("%s/internal/vote/clear_all", addr), nil) - if err != nil { - return fmt.Errorf("create request: %w", err) - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return fmt.Errorf("send request: %w", err) - } - - if resp.StatusCode != 200 { - body, err := io.ReadAll(resp.Body) - if err != nil { - body = []byte("can not read body") - } - return fmt.Errorf("got %s: %s", resp.Status, body) - } - - return nil -} - -func startPoll(ctx context.Context, db *postgresTestData, pollID int) error { - db.addTestData(ctx, dsmock.YAMLData(fmt.Sprintf(`--- - poll/%d: - meeting_id: 1 - type: named - state: started - backend: fast - pollmethod: Y - entitled_group_ids: [1] - global_yes: true - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll - - group/1/meeting_user_ids: [10] - meeting_user/10: - user_id: 1 - meeting_id: 1 - group_ids: [1] - user/1: - is_present_in_meeting_ids: [1] - meeting_user_ids: [10] - meeting/1/id: 5 - `, - pollID))) - - req, err := http.NewRequest("GET", fmt.Sprintf("%s/internal/vote/start?id=%d", addr, pollID), nil) - if err != nil { - return fmt.Errorf("create request: %w", err) - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return fmt.Errorf("send request: %w", err) - } - - if resp.StatusCode != 200 { - body, err := io.ReadAll(resp.Body) - if err != nil { - body = []byte("can not read body") - } - return fmt.Errorf("got %s: %s", resp.Status, body) - } - - return nil -} - -func vote(pollID, userID int, body io.Reader) error { - cookie, headerName, headerValue, err := authtest.ValidTokens([]byte("openslides"), []byte("openslides"), userID) - if err != nil { - return fmt.Errorf("creating user tokens: %w", err) - } - - req, err := http.NewRequest("GET", fmt.Sprintf("%s/system/vote?id=%d", addr, pollID), body) - if err != nil { - return fmt.Errorf("create request: %w", err) - } - - req.AddCookie(cookie) - req.Header.Add(headerName, headerValue) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return fmt.Errorf("send request: %w", err) - } - - if resp.StatusCode != 200 { - body, err := io.ReadAll(resp.Body) - if err != nil { - body = []byte("can not read body") - } - return fmt.Errorf("got %s: %s", resp.Status, body) - } - - return nil -} - -func stopPoll(pollID int) ([]byte, error) { - req, err := http.NewRequest("GET", fmt.Sprintf("%s/internal/vote/stop?id=%d", addr, pollID), nil) - if err != nil { - return nil, fmt.Errorf("create request: %w", err) - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, fmt.Errorf("send request: %w", err) - } - - if resp.StatusCode != 200 { - body, err := io.ReadAll(resp.Body) - if err != nil { - body = []byte("can not read body") - } - return nil, fmt.Errorf("got %s: %s", resp.Status, body) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("reading body: %w", err) - } - - return body, nil -} - -func clearPoll(pollID int) error { - req, err := http.NewRequest("GET", fmt.Sprintf("%s/internal/vote/clear?id=%d", addr, pollID), nil) - if err != nil { - return fmt.Errorf("create request: %w", err) - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return fmt.Errorf("send request: %w", err) - } - - if resp.StatusCode != 200 { - body, err := io.ReadAll(resp.Body) - if err != nil { - body = []byte("can not read body") - } - return fmt.Errorf("got %s: %s", resp.Status, body) - } - - return nil -} - -func skip(t *testing.T) { - if _, ok := os.LookupEnv("VOTE_SYSTEM_TEST"); !ok { - t.SkipNow() - } -} diff --git a/vote/error.go b/vote/error.go index 3ad45579..8c21c3a7 100644 --- a/vote/error.go +++ b/vote/error.go @@ -8,14 +8,10 @@ const ( // ErrInternal should not happen. ErrInternal TypeError = iota - // ErrExists happens, when start is called with an poll ID that already - // exists. - ErrExists - // ErrNotExists happens when an operation is performed on an unknown poll. ErrNotExists - // ErrInvalid happens, when the vote data is invalid. + // ErrInvalid happens, when the poll or vote data is invalid. ErrInvalid // ErrDoubleVote happens on a vote request, when the user tries to vote for a @@ -26,8 +22,8 @@ const ( // anonymous or is not allowed to vote. ErrNotAllowed - // ErrStopped happens when a user tries to vote on a stopped poll. - ErrStopped + // ErrNotStarted happens when a user tries to vote on a stopped poll. + ErrNotStarted ) // TypeError is an error that can happend in this API. @@ -36,9 +32,6 @@ type TypeError int // Type returns a name for the error. func (err TypeError) Type() string { switch err { - case ErrExists: - return "exist" - case ErrNotExists: return "not-exist" @@ -51,8 +44,8 @@ func (err TypeError) Type() string { case ErrNotAllowed: return "not-allowed" - case ErrStopped: - return "stopped" + case ErrNotStarted: + return "not-started" default: return "internal" @@ -62,9 +55,6 @@ func (err TypeError) Type() string { func (err TypeError) Error() string { var msg string switch err { - case ErrExists: - msg = "Poll does already exist with differet config" - case ErrNotExists: msg = "Poll does not exist" @@ -74,7 +64,7 @@ func (err TypeError) Error() string { case ErrDoubleVote: msg = "Not the first vote" - case ErrStopped: + case ErrNotStarted: msg = "The vote is not open for votes" case ErrNotAllowed: @@ -84,7 +74,11 @@ func (err TypeError) Error() string { msg = "Ups, something went wrong!" } - return fmt.Sprintf(`{"error":"%s","message":"%s"}`, err.Type(), msg) + return msg +} + +func invalidVote(msg string, a ...any) error { + return MessageErrorf(ErrInvalid, msg, a...) } type messageError struct { diff --git a/vote/flow.go b/vote/flow.go index ca9102c7..9f6e8f27 100644 --- a/vote/flow.go +++ b/vote/flow.go @@ -7,16 +7,19 @@ import ( "github.com/OpenSlides/openslides-go/datastore/cache" "github.com/OpenSlides/openslides-go/datastore/flow" "github.com/OpenSlides/openslides-go/environment" + "github.com/jackc/pgx/v5/pgxpool" ) // Flow initializes a cached connection to postgres. -func Flow(lookup environment.Environmenter, messageBus flow.Updater) (flow.Flow, error) { - postgres, err := datastore.NewFlowPostgres(lookup, messageBus) +func Flow(lookup environment.Environmenter) (flow.Flow, *pgxpool.Pool, error) { + postgres, err := datastore.NewFlowPostgres(lookup) if err != nil { - return nil, fmt.Errorf("init postgres: %w", err) + return nil, nil, fmt.Errorf("init postgres: %w", err) } + pool := postgres.Pool + cache := cache.New(postgres) - return cache, nil + return cache, pool, nil } diff --git a/vote/http/error.go b/vote/http/error.go index 8277c7f1..49dc4f8a 100644 --- a/vote/http/error.go +++ b/vote/http/error.go @@ -2,33 +2,27 @@ package http import ( "context" - "encoding/json" "errors" "fmt" "io" "net/http" - "github.com/OpenSlides/openslides-vote-service/log" "github.com/OpenSlides/openslides-vote-service/vote" ) -func handleInternal(handler Handler) http.Handler { - return resolveError(handler, true) -} +type logger func(fmt string, a ...any) (int, error) -func handleExternal(handler Handler) http.Handler { - return resolveError(handler, false) -} +func getResolveError(logger logger) func(handler Handler) http.HandlerFunc { + return func(handler Handler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + err := handler.ServeHTTP(w, r) + if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return + } -func resolveError(handler Handler, internalRoute bool) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - err := handler.ServeHTTP(w, r) - if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return + writeStatusCode(w, err) + writeFormattedError(w, err, logger) } - - writeStatusCode(w, err) - writeFormattedError(w, err, internalRoute) } } @@ -46,40 +40,31 @@ func writeStatusCode(w http.ResponseWriter, err error) { statusCode = 500 } - log.Debug("HTTP: Returning status %d", statusCode) w.WriteHeader(statusCode) } -func writeFormattedError(w io.Writer, err error, internalRoute bool) { +func writeFormattedError(w io.Writer, err error, logger logger) { errType := "internal" + msg := err.Error() var errTyped interface { error Type() string } if errors.As(err, &errTyped) { errType = errTyped.Type() + msg = errTyped.Error() } - msg := err.Error() if errType == "internal" { - log.Info("Error: %s", msg) - if !internalRoute { - msg = vote.ErrInternal.Error() - } + logger("Error: %s\n", msg) + msg = vote.ErrInternal.Error() } - out := struct { - Error string `json:"error"` - MSG string `json:"message"` - }{ - errType, - msg, - } + w.Write([]byte(errorAsJSON(errType, msg))) +} - if err := json.NewEncoder(w).Encode(out); err != nil { - log.Info("Error encoding error message: %v", err) - fmt.Fprint(w, `{"error":"internal", "message":"Something went wrong encoding the error message"}`) - } +func errorAsJSON(errType string, msg string) string { + return fmt.Sprintf(`{"error":"%s","message":"%s"}`, errType, msg) } type statusCodeError struct { diff --git a/vote/http/http.go b/vote/http/http.go index e072a0c6..a043c606 100644 --- a/vote/http/http.go +++ b/vote/http/http.go @@ -9,26 +9,25 @@ import ( "net" "net/http" "strconv" - "strings" - "time" "github.com/OpenSlides/openslides-go/environment" - "github.com/OpenSlides/openslides-vote-service/log" "github.com/OpenSlides/openslides-vote-service/vote" ) -var envVotePort = environment.NewVariable("VOTE_PORT", "9013", "Port on which the service listen on.") +var envVotePort = environment.NewVariable("VOTE_PORT", "9013", "Port on which the service listens on.") // Server can start the service on a port. type Server struct { - Addr string - lst net.Listener + Addr string + lst net.Listener + logger logger } // New initializes a new Server. -func New(lookup environment.Environmenter) Server { +func New(lookup environment.Environmenter, logger logger) Server { return Server{ - Addr: ":" + envVotePort.Value(lookup), + Addr: ":" + envVotePort.Value(lookup), + logger: logger, } } @@ -48,12 +47,7 @@ func (s *Server) StartListener() error { // Run starts the http service. func (s *Server) Run(ctx context.Context, auth authenticater, service *vote.Vote) error { - ticketProvider := func() (<-chan time.Time, func()) { - ticker := time.NewTicker(time.Second) - return ticker.C, ticker.Stop - } - - mux := registerHandlers(service, auth, ticketProvider) + mux := registerHandlers(service, auth, s.logger) srv := &http.Server{ Handler: mux, @@ -77,22 +71,22 @@ func (s *Server) Run(ctx context.Context, auth authenticater, service *vote.Vote } } - log.Info("Listen on %s\n", s.Addr) + s.logger("Listen on %s\n", s.Addr) if err := srv.Serve(s.lst); err != http.ErrServerClosed { - return fmt.Errorf("HTTP Server failed: %v", err) + return fmt.Errorf("HTTP Server failed: %w", err) } return <-wait } type voteService interface { + creater + updater + deleter starter - stopper - clearer - clearAller - allLiveVotes + finalizer + reseter voter - haveIvoteder } type authenticater interface { @@ -100,138 +94,130 @@ type authenticater interface { FromContext(context.Context) int } -func registerHandlers(service voteService, auth authenticater, ticketProvider func() (<-chan time.Time, func())) *http.ServeMux { - const ( - internal = "/internal/vote" - external = "/system/vote" - ) +func registerHandlers(service voteService, auth authenticater, logger logger) *http.ServeMux { + const base = "/system/vote" + + resolveError := getResolveError(logger) mux := http.NewServeMux() - mux.Handle(internal+"/start", handleInternal(handleStart(service))) - mux.Handle(internal+"/stop", handleInternal(handleStop(service))) - mux.Handle(internal+"/clear", handleInternal(handleClear(service))) - mux.Handle(internal+"/clear_all", handleInternal(handleClearAll(service))) - mux.Handle(internal+"/live_votes", handleInternal(handleAllVotedIDs(service, ticketProvider))) - mux.Handle(external+"", handleExternal(handleVote(service, auth))) - mux.Handle(external+"/voted", handleExternal(handleVoted(service, auth))) - mux.Handle(external+"/health", handleExternal(handleHealth())) + mux.Handle(base+"/create", resolveError(handleCreate(service, auth))) + mux.Handle(base+"/update", resolveError(handleUpdate(service, auth))) + mux.Handle(base+"/delete", resolveError(handleDelete(service, auth))) + mux.Handle(base+"/start", resolveError(handleStart(service, auth))) + mux.Handle(base+"/finalize", resolveError(handleFinalize(service, auth))) + mux.Handle(base+"/reset", resolveError(handleReset(service, auth))) + mux.Handle(base, resolveError(handleVote(service, auth))) + mux.Handle(base+"/health", resolveError(handleHealth())) return mux } -type starter interface { - Start(ctx context.Context, pollID int) error +type creater interface { + Create(ctx context.Context, requestUserID int, r io.Reader) (int, error) } -func handleStart(start starter) HandlerFunc { +func handleCreate(create creater, auth authenticater) HandlerFunc { return func(w http.ResponseWriter, r *http.Request) error { - log.Info("Receiving start request") - w.Header().Set("Content-Type", "application/json") + ctx, uid, err := prepareRequest(w, r, auth) + if err != nil { + return fmt.Errorf("prepare request: %w", err) + } - id, err := pollID(r) + pollID, err := create.Create(ctx, uid, r.Body) if err != nil { - return vote.WrapError(vote.ErrInvalid, err) + return fmt.Errorf("create: %w", err) } - return start.Start(r.Context(), id) + result := struct { + PollID int `json:"poll_id"` + }{pollID} + + if err := json.NewEncoder(w).Encode(result); err != nil { + return fmt.Errorf("encoding and sending poll id: %w", err) + } + + return nil } } -// stopper stops a poll. It sets the state of the poll, so that no other user -// can vote. It writes the vote results to the writer. -type stopper interface { - Stop(ctx context.Context, pollID int) (vote.StopResult, error) +type updater interface { + Update(ctx context.Context, pollID int, requestUserID int, r io.Reader) error } -func handleStop(stop stopper) HandlerFunc { +func handleUpdate(update updater, auth authenticater) HandlerFunc { return func(w http.ResponseWriter, r *http.Request) error { - log.Info("Receiving stop request") - w.Header().Set("Content-Type", "application/json") - - id, err := pollID(r) + ctx, uid, err := prepareRequest(w, r, auth) if err != nil { - return vote.WrapError(vote.ErrInvalid, err) + return fmt.Errorf("prepare request: %w", err) } - result, err := stop.Stop(r.Context(), id) + pollID, err := pollID(r) if err != nil { - return err - } - - // Convert vote objects to json.RawMessage - encodableObjects := make([]json.RawMessage, len(result.Votes)) - for i := range result.Votes { - encodableObjects[i] = result.Votes[i] - } - - if result.UserIDs == nil { - result.UserIDs = []int{} + return vote.WrapError(vote.ErrInvalid, err) } - out := struct { - Votes []json.RawMessage `json:"votes"` - Users []int `json:"user_ids"` - }{ - encodableObjects, - result.UserIDs, + if err := update.Update(ctx, pollID, uid, r.Body); err != nil { + return fmt.Errorf("update: %w", err) } - if err := json.NewEncoder(w).Encode(out); err != nil { - return fmt.Errorf("encoding and sending objects: %w", err) - } return nil } } -type clearer interface { - Clear(ctx context.Context, pollID int) error +type deleter interface { + Delete(ctx context.Context, pollID int, requestUserID int) error } -func handleClear(clear clearer) HandlerFunc { +func handleDelete(delete deleter, auth authenticater) HandlerFunc { return func(w http.ResponseWriter, r *http.Request) error { - log.Info("Receiving clear request") - w.Header().Set("Content-Type", "application/json") + ctx, uid, err := prepareRequest(w, r, auth) + if err != nil { + return fmt.Errorf("prepare request: %w", err) + } - id, err := pollID(r) + pollID, err := pollID(r) if err != nil { return vote.WrapError(vote.ErrInvalid, err) } - return clear.Clear(r.Context(), id) + if err := delete.Delete(ctx, pollID, uid); err != nil { + return fmt.Errorf("delete: %w", err) + } + + return nil } } -type clearAller interface { - ClearAll(ctx context.Context) error +type starter interface { + Start(ctx context.Context, pollID int, requestUserID int) error } -func handleClearAll(clear clearAller) HandlerFunc { +func handleStart(start starter, auth authenticater) HandlerFunc { return func(w http.ResponseWriter, r *http.Request) error { - log.Info("Receiving clear all request") - w.Header().Set("Content-Type", "application/json") + ctx, uid, err := prepareRequest(w, r, auth) + if err != nil { + return fmt.Errorf("prepare request: %w", err) + } + + id, err := pollID(r) + if err != nil { + return vote.WrapError(vote.ErrInvalid, err) + } - return clear.ClearAll(r.Context()) + return start.Start(ctx, id, uid) } } -type voter interface { - Vote(ctx context.Context, pollID, requestUser int, r io.Reader) error +type finalizer interface { + Finalize(ctx context.Context, pollID int, requestUserID int, publish bool, anonymize bool) error } -func handleVote(service voter, auth authenticater) HandlerFunc { +func handleFinalize(finalize finalizer, auth authenticater) HandlerFunc { return func(w http.ResponseWriter, r *http.Request) error { - log.Info("Receiving vote request") - w.Header().Set("Content-Type", "application/json") - - ctx, err := auth.Authenticate(w, r) + ctx, uid, err := prepareRequest(w, r, auth) if err != nil { - return err - } - - uid := auth.FromContext(ctx) - if uid == 0 { - return statusCode(401, vote.MessageError(vote.ErrNotAllowed, "Anonymous user can not vote")) + return fmt.Errorf("prepare request: %w", err) } id, err := pollID(r) @@ -239,129 +225,54 @@ func handleVote(service voter, auth authenticater) HandlerFunc { return vote.WrapError(vote.ErrInvalid, err) } - return service.Vote(ctx, id, uid, r.Body) + publish := r.URL.Query().Has("publish") + anonymize := r.URL.Query().Has("anonymize") + + return finalize.Finalize(ctx, id, uid, publish, anonymize) } } -type haveIvoteder interface { - Voted(ctx context.Context, pollIDs []int, requestUser int) (map[int][]int, error) +type reseter interface { + Reset(ctx context.Context, pollID int, requestUserID int) error } -func handleVoted(voted haveIvoteder, auth authenticater) HandlerFunc { +func handleReset(reset reseter, auth authenticater) HandlerFunc { return func(w http.ResponseWriter, r *http.Request) error { - log.Info("Receiving has voted request") - w.Header().Set("Content-Type", "application/json") - - ctx, err := auth.Authenticate(w, r) + ctx, uid, err := prepareRequest(w, r, auth) if err != nil { - return err + return fmt.Errorf("prepare request: %w", err) } - uid := auth.FromContext(ctx) - if uid == 0 { - return statusCode(401, vote.MessageError(vote.ErrNotAllowed, "Anonymous user can not vote")) - } - - pollIDs, err := pollsID(r) + pollID, err := pollID(r) if err != nil { return vote.WrapError(vote.ErrInvalid, err) } - voted, err := voted.Voted(ctx, pollIDs, uid) - if err != nil { - return err - } - - if err := json.NewEncoder(w).Encode(voted); err != nil { - return fmt.Errorf("encoding and sending objects: %w", err) + if err := reset.Reset(ctx, pollID, uid); err != nil { + return fmt.Errorf("delete: %w", err) } return nil } } -type allLiveVotes interface { - AllLiveVotes(ctx context.Context) map[int]map[int]*string +type voter interface { + Vote(ctx context.Context, pollID, requestUser int, r io.Reader) error } -// handleAllVotedIDs opens an http connection, that the server never closes. -// -// When the connection is established, it returns for all active polls the votes -// of each user. -// -// Every second, it checks for new votes or polls. If there is new data, it -// returns an dictonary from poll id to user id to there vote. -// -// If an poll is not active anymore, it returns a `null`-value for it. -// -// This system can only add users. It can fail, if a poll is resettet and -// started in less then a second. -func handleAllVotedIDs(voteCounter allLiveVotes, eventer func() (<-chan time.Time, func())) HandlerFunc { +func handleVote(service voter, auth authenticater) HandlerFunc { return func(w http.ResponseWriter, r *http.Request) error { - log.Info("Receiving all voted ids") - w.Header().Set("Content-Type", "application/json") + ctx, uid, err := prepareRequest(w, r, auth) + if err != nil { + return fmt.Errorf("prepare request: %w", err) + } - encoder := json.NewEncoder(w) - - event, cancel := eventer() - defer cancel() - - var voterMemory map[int]map[int]*string - firstData := true - for { - newLiveVotes := voteCounter.AllLiveVotes(r.Context()) - diff := make(map[int]map[int]*string) - - if voterMemory == nil { - voterMemory = newLiveVotes - diff = newLiveVotes - } else { - for pollID, userID2Vote := range newLiveVotes { - if oldUserID2Vote, ok := voterMemory[pollID]; !ok { - voterMemory[pollID] = userID2Vote - diff[pollID] = userID2Vote - } else { - - for newUserID, vote := range userID2Vote { - if _, contains := oldUserID2Vote[newUserID]; !contains { - if _, ok := diff[pollID]; !ok { - diff[pollID] = make(map[int]*string) - } - voterMemory[pollID][newUserID] = vote - diff[pollID][newUserID] = vote - } - } - } - } - for pollID := range voterMemory { - if _, ok := newLiveVotes[pollID]; !ok { - delete(voterMemory, pollID) - diff[pollID] = nil - } - } - } - - if firstData || len(diff) > 0 { - firstData = false - if err := encoder.Encode(diff); err != nil { - return err - } - } - - // This could be in the if(count) block, but the Flush is used - // in the tests and has to be called, even when there is no data - // to sent. - w.(http.Flusher).Flush() - - select { - case _, ok := <-event: - if !ok { - return nil - } - case <-r.Context().Done(): - return nil - } + id, err := pollID(r) + if err != nil { + return vote.WrapError(vote.ErrInvalid, err) } + + return service.Vote(ctx, id, uid, r.Body) } } @@ -434,24 +345,6 @@ func pollID(r *http.Request) (int, error) { return id, nil } -func pollsID(r *http.Request) ([]int, error) { - rawIDs := strings.Split(r.URL.Query().Get("ids"), ",") - if len(rawIDs) == 0 { - return nil, fmt.Errorf("no ids argument provided") - } - - ids := make([]int, len(rawIDs)) - for i, rawID := range rawIDs { - id, err := strconv.Atoi(rawID) - if err != nil { - return nil, fmt.Errorf("%dth id invalid. Expected int, got %s", i, rawID) - } - ids[i] = id - } - - return ids, nil -} - // Handler is like http.Handler but returns an error type Handler interface { ServeHTTP(w http.ResponseWriter, r *http.Request) error @@ -463,3 +356,28 @@ type HandlerFunc func(w http.ResponseWriter, r *http.Request) error func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) error { return f(w, r) } + +// prepare Requests bundles the functionality needed for all handlers. +// +// - sets the header Content-Type to application/json +// - authenticates the user +// - returns the authenticated ctx and the request user id +func prepareRequest(w http.ResponseWriter, r *http.Request, auth authenticater) (context.Context, int, error) { + w.Header().Set("Content-Type", "application/json") + + if r.Method != http.MethodPost { + return nil, 0, vote.MessageError(vote.ErrInvalid, "Only POST method is allowed") + } + + ctx, err := auth.Authenticate(w, r) + if err != nil { + return nil, 0, fmt.Errorf("authenticate request user: %w", err) + } + + uid := auth.FromContext(ctx) + if uid == 0 { + return nil, 0, statusCode(401, vote.MessageError(vote.ErrNotAllowed, "Anonymous user can not use the vote service")) + } + + return ctx, uid, nil +} diff --git a/vote/http/http_run_test.go b/vote/http/http_run_test.go index 6f726c2b..9707fd9f 100644 --- a/vote/http/http_run_test.go +++ b/vote/http/http_run_test.go @@ -10,7 +10,6 @@ import ( "github.com/OpenSlides/openslides-go/datastore/dsmock" "github.com/OpenSlides/openslides-go/environment" - "github.com/OpenSlides/openslides-vote-service/backend/memory" "github.com/OpenSlides/openslides-vote-service/vote" votehttp "github.com/OpenSlides/openslides-vote-service/vote/http" ) @@ -42,10 +41,10 @@ func (a *autherStub) FromContext(context.Context) int { func TestRun(t *testing.T) { ctx := t.Context() - backend := memory.New() ds := dsmock.NewFlow(nil) - service, _, _ := vote.New(ctx, backend, backend, ds, true) - httpServer := votehttp.New(environment.ForTests(map[string]string{"VOTE_PORT": "0"})) + service, _, _ := vote.New(ctx, ds, nil) + testLogger := func(fmt string, a ...any) (int, error) { return 0, nil } + httpServer := votehttp.New(environment.ForTests(map[string]string{"VOTE_PORT": "0"}), testLogger) if err := httpServer.StartListener(); err != nil { t.Fatalf("start listening: %v", err) @@ -63,13 +62,13 @@ func TestRun(t *testing.T) { t.Run("URLs", func(t *testing.T) { for _, url := range []string{ - "/internal/vote/start", - "/internal/vote/stop", - "/internal/vote/clear", - "/internal/vote/clear_all", - "/internal/vote/live_votes", + "/system/vote/create", + "/system/vote/update", + "/system/vote/delete", + "/system/vote/start", + "/system/vote/finalize", + "/system/vote/reset", "/system/vote", - "/system/vote/voted", "/system/vote/health", } { resp, err := http.Get(fmt.Sprintf("http://%s%s", httpServer.Addr, url)) diff --git a/vote/http/http_test.go b/vote/http/http_test.go index 5d82a4b9..22fb4a18 100644 --- a/vote/http/http_test.go +++ b/vote/http/http_test.go @@ -7,29 +7,145 @@ import ( "io" "net/http" "net/http/httptest" - "reflect" "strings" "testing" - "time" "github.com/OpenSlides/openslides-vote-service/vote" ) -type starterStub struct { - id int - expectErr error +type createrStub struct { + requestUserID int + body string + pollID int + expectErr error } -func (c *starterStub) Start(ctx context.Context, pollID int) error { - c.id = pollID - return c.expectErr +func (c *createrStub) Create(ctx context.Context, requestUserID int, r io.Reader) (int, error) { + c.requestUserID = requestUserID + + body, err := io.ReadAll(r) + if err != nil { + return 0, err + } + c.body = string(body) + + return c.pollID, c.expectErr } -func TestHandleStart(t *testing.T) { - starter := &starterStub{} +func TestHandleCreate(t *testing.T) { + creater := &createrStub{pollID: 42} + auth := &AutherStub{userID: 1} - url := "/vote/start" - mux := handleInternal(handleStart(starter)) + url := "/system/vote/create" + mux := testresolveError(handleCreate(creater, auth)) + + t.Run("Valid", func(t *testing.T) { + resp := httptest.NewRecorder() + mux.ServeHTTP(resp, httptest.NewRequest("POST", url, strings.NewReader("create data"))) + + if resp.Result().StatusCode != 200 { + t.Errorf("Got status %s, expected 200 - OK", resp.Result().Status) + } + + if creater.requestUserID != 1 { + t.Errorf("Create was called with userID %d, expected 1", creater.requestUserID) + } + + if creater.body != "create data" { + t.Errorf("Create was called with body `%s`, expected `create data`", creater.body) + } + + var body struct { + PollID int `json:"poll_id"` + } + + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decoding resp body: %v", err) + } + + if body.PollID != 42 { + t.Errorf("Got poll_id %d, expected 42", body.PollID) + } + }) + + t.Run("Error invalid", func(t *testing.T) { + creater.expectErr = vote.ErrInvalid + + resp := httptest.NewRecorder() + mux.ServeHTTP(resp, httptest.NewRequest("POST", url, strings.NewReader("invalid data"))) + + if resp.Result().StatusCode != 400 { + t.Errorf("Got status %s, expected 400", resp.Result().Status) + } + + var body struct { + Error string `json:"error"` + } + + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decoding resp body: %v", err) + } + + if body.Error != "invalid" { + t.Errorf("Got error `%s`, expected `invalid`", body.Error) + } + }) + + t.Run("Internal error", func(t *testing.T) { + creater.expectErr = errors.New("test internal error") + + resp := httptest.NewRecorder() + mux.ServeHTTP(resp, httptest.NewRequest("POST", url, strings.NewReader("data"))) + + if resp.Result().StatusCode != 500 { + t.Errorf("Got status %s, expected 500", resp.Result().Status) + } + + var body struct { + Error string `json:"error"` + MSG string `json:"message"` + } + + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decoding resp body: %v", err) + } + + if body.Error != "internal" { + t.Errorf("Got error `%s`, expected `internal`", body.Error) + } + + if body.MSG != "Ups, something went wrong!" { + t.Errorf("Got error message `%s`, expected `Ups, something went wrong!`", body.MSG) + } + }) +} + +type updaterStub struct { + pollID int + requestUserID int + body string + expectErr error +} + +func (u *updaterStub) Update(ctx context.Context, pollID int, requestUserID int, r io.Reader) error { + u.pollID = pollID + u.requestUserID = requestUserID + + body, err := io.ReadAll(r) + if err != nil { + return err + } + u.body = string(body) + + return u.expectErr +} + +func TestHandleUpdate(t *testing.T) { + updater := &updaterStub{} + auth := &AutherStub{userID: 1} + + url := "/system/vote/update" + mux := testresolveError(handleUpdate(updater, auth)) t.Run("No id", func(t *testing.T) { resp := httptest.NewRecorder() @@ -51,22 +167,30 @@ func TestHandleStart(t *testing.T) { t.Run("Valid", func(t *testing.T) { resp := httptest.NewRecorder() - mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=1", strings.NewReader("request body"))) + mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=1", strings.NewReader("update data"))) if resp.Result().StatusCode != 200 { t.Errorf("Got status %s, expected 200 - OK", resp.Result().Status) } - if starter.id != 1 { - t.Errorf("Start was called with id %d, expected 1", starter.id) + if updater.pollID != 1 { + t.Errorf("Update was called with pollID %d, expected 1", updater.pollID) + } + + if updater.requestUserID != 1 { + t.Errorf("Update was called with userID %d, expected 1", updater.requestUserID) + } + + if updater.body != "update data" { + t.Errorf("Update was called with body `%s`, expected `update data`", updater.body) } }) - t.Run("Exist error", func(t *testing.T) { - starter.expectErr = vote.ErrExists + t.Run("Error not exist", func(t *testing.T) { + updater.expectErr = vote.ErrNotExists resp := httptest.NewRecorder() - mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=1", strings.NewReader("request body"))) + mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=1", strings.NewReader("data"))) if resp.Result().StatusCode != 400 { t.Errorf("Got status %s, expected 400", resp.Result().Status) @@ -80,16 +204,16 @@ func TestHandleStart(t *testing.T) { t.Fatalf("decoding resp body: %v", err) } - if body.Error != "exist" { - t.Errorf("Got error `%s`, expected `exist`", body.Error) + if body.Error != "not-exist" { + t.Errorf("Got error `%s`, expected `not-exist`", body.Error) } }) t.Run("Internal error", func(t *testing.T) { - starter.expectErr = errors.New("TEST_Error") + updater.expectErr = errors.New("test internal error") resp := httptest.NewRecorder() - mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=1", strings.NewReader("request body"))) + mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=1", strings.NewReader("data"))) if resp.Result().StatusCode != 500 { t.Errorf("Got status %s, expected 500", resp.Result().Status) @@ -108,38 +232,30 @@ func TestHandleStart(t *testing.T) { t.Errorf("Got error `%s`, expected `internal`", body.Error) } - if body.MSG != "TEST_Error" { - t.Errorf("Got error message `%s`, expected `TEST_Error`", body.MSG) + if body.MSG != "Ups, something went wrong!" { + t.Errorf("Got error message `%s`, expected `Ups, something went wrong!`", body.MSG) } }) } -type stopperStub struct { - id int - expectErr error - - expectedVotes [][]byte - expectedUserIDs []int +type deleterStub struct { + pollID int + requestUserID int + expectErr error } -func (s *stopperStub) Stop(ctx context.Context, pollID int) (vote.StopResult, error) { - s.id = pollID - - if s.expectErr != nil { - return vote.StopResult{}, s.expectErr - } - - return vote.StopResult{ - Votes: s.expectedVotes, - UserIDs: s.expectedUserIDs, - }, nil +func (d *deleterStub) Delete(ctx context.Context, pollID int, requestUserID int) error { + d.pollID = pollID + d.requestUserID = requestUserID + return d.expectErr } -func TestHandleStop(t *testing.T) { - stopper := &stopperStub{} +func TestHandleDelete(t *testing.T) { + deleter := &deleterStub{} + auth := &AutherStub{userID: 1} - url := "/vote/stop" - mux := handleInternal(handleStop(stopper)) + url := "/system/vote/delete" + mux := testresolveError(handleDelete(deleter, auth)) t.Run("No id", func(t *testing.T) { resp := httptest.NewRecorder() @@ -150,9 +266,16 @@ func TestHandleStop(t *testing.T) { } }) - t.Run("Valid", func(t *testing.T) { - stopper.expectedVotes = [][]byte{[]byte(`"some values"`)} + t.Run("Invalid id", func(t *testing.T) { + resp := httptest.NewRecorder() + mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=value", nil)) + if resp.Result().StatusCode != 400 { + t.Errorf("Got status %s, expected 400 - Bad Request", resp.Result().Status) + } + }) + + t.Run("Valid", func(t *testing.T) { resp := httptest.NewRecorder() mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=1", nil)) @@ -160,18 +283,17 @@ func TestHandleStop(t *testing.T) { t.Errorf("Got status %s, expected 200 - OK", resp.Result().Status) } - if stopper.id != 1 { - t.Errorf("Stopper was called with id %d, expected 1", stopper.id) + if deleter.pollID != 1 { + t.Errorf("Delete was called with pollID %d, expected 1", deleter.pollID) } - expect := `{"votes":["some values"],"user_ids":[]}` - if trimed := strings.TrimSpace(resp.Body.String()); trimed != expect { - t.Errorf("Got body:\n`%s`, expected:\n`%s`", trimed, expect) + if deleter.requestUserID != 1 { + t.Errorf("Delete was called with userID %d, expected 1", deleter.requestUserID) } }) - t.Run("Not Exist error", func(t *testing.T) { - stopper.expectErr = vote.ErrNotExists + t.Run("Error not exist", func(t *testing.T) { + deleter.expectErr = vote.ErrNotExists resp := httptest.NewRecorder() mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=1", nil)) @@ -192,23 +314,52 @@ func TestHandleStop(t *testing.T) { t.Errorf("Got error `%s`, expected `not-exist`", body.Error) } }) + + t.Run("Internal error", func(t *testing.T) { + deleter.expectErr = errors.New("test internal error") + + resp := httptest.NewRecorder() + mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=1", nil)) + + if resp.Result().StatusCode != 500 { + t.Errorf("Got status %s, expected 500", resp.Result().Status) + } + + var body struct { + Error string `json:"error"` + MSG string `json:"message"` + } + + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decoding resp body: %v", err) + } + + if body.Error != "internal" { + t.Errorf("Got error `%s`, expected `internal`", body.Error) + } + + if body.MSG != "Ups, something went wrong!" { + t.Errorf("Got error message `%s`, expected `Ups, something went wrong!`", body.MSG) + } + }) } -type clearerStub struct { +type starterStub struct { id int expectErr error } -func (c *clearerStub) Clear(ctx context.Context, pollID int) error { +func (c *starterStub) Start(ctx context.Context, pollID int, requestUserID int) error { c.id = pollID return c.expectErr } -func TestHandleClear(t *testing.T) { - clearer := &clearerStub{} +func TestHandleStart(t *testing.T) { + starter := &starterStub{} + auth := &AutherStub{userID: 1} - url := "/vote/clear" - mux := handleInternal(handleClear(clearer)) + url := "/vote/start" + mux := testresolveError(handleStart(starter, auth)) t.Run("No id", func(t *testing.T) { resp := httptest.NewRecorder() @@ -219,24 +370,33 @@ func TestHandleClear(t *testing.T) { } }) + t.Run("Invalid id", func(t *testing.T) { + resp := httptest.NewRecorder() + mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=value", nil)) + + if resp.Result().StatusCode != 400 { + t.Errorf("Got status %s, expected 400 - Bad Request", resp.Result().Status) + } + }) + t.Run("Valid", func(t *testing.T) { resp := httptest.NewRecorder() - mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=1", nil)) + mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=1", strings.NewReader("request body"))) if resp.Result().StatusCode != 200 { t.Errorf("Got status %s, expected 200 - OK", resp.Result().Status) } - if clearer.id != 1 { - t.Errorf("Clearer was called with id %d, expected 1", clearer.id) + if starter.id != 1 { + t.Errorf("Start was called with id %d, expected 1", starter.id) } }) - t.Run("Not Exist error", func(t *testing.T) { - clearer.expectErr = vote.ErrNotExists + t.Run("Error invalid", func(t *testing.T) { + starter.expectErr = vote.ErrInvalid resp := httptest.NewRecorder() - mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=1", nil)) + mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=1", strings.NewReader("request body"))) if resp.Result().StatusCode != 400 { t.Errorf("Got status %s, expected 400", resp.Result().Status) @@ -250,40 +410,93 @@ func TestHandleClear(t *testing.T) { t.Fatalf("decoding resp body: %v", err) } - if body.Error != "not-exist" { - t.Errorf("Got error `%s`, expected `not-exist`", body.Error) + if body.Error != "invalid" { + t.Errorf("Got error `%s`, expected `invalid`", body.Error) + } + }) + + t.Run("Internal error", func(t *testing.T) { + starter.expectErr = errors.New("test internal error") + + resp := httptest.NewRecorder() + mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=1", strings.NewReader("request body"))) + + if resp.Result().StatusCode != 500 { + t.Errorf("Got status %s, expected 500", resp.Result().Status) + } + + var body struct { + Error string `json:"error"` + MSG string `json:"message"` + } + + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decoding resp body: %v", err) + } + + if body.Error != "internal" { + t.Errorf("Got error `%s`, expected `internal`", body.Error) + } + + if body.MSG != "Ups, something went wrong!" { + t.Errorf("Got error message `%s`, expected `Ups, something went wrong!`", body.MSG) } }) } -type clearAllerStub struct { +type finalizerStub struct { + id int + publish bool + anonymize bool expectErr error } -func (c *clearAllerStub) ClearAll(ctx context.Context) error { - return c.expectErr +func (s *finalizerStub) Finalize(ctx context.Context, pollID int, requestUserID int, publish bool, anonymize bool) error { + s.id = pollID + s.publish = publish + s.anonymize = anonymize + + if s.expectErr != nil { + return s.expectErr + } + + return nil } -func TestHandleClearAll(t *testing.T) { - clearAller := &clearAllerStub{} +func TestHandleFinalize(t *testing.T) { + finalizer := &finalizerStub{} + auth := &AutherStub{userID: 1} - url := "/vote/clear_all" - mux := handleInternal(handleClearAll(clearAller)) + url := "/vote/finalize" + mux := testresolveError(handleFinalize(finalizer, auth)) - t.Run("Valid", func(t *testing.T) { + t.Run("No id", func(t *testing.T) { resp := httptest.NewRecorder() mux.ServeHTTP(resp, httptest.NewRequest("POST", url, nil)) + if resp.Result().StatusCode != 400 { + t.Errorf("Got status %s, expected 400 - Bad Request", resp.Result().Status) + } + }) + + t.Run("Valid", func(t *testing.T) { + resp := httptest.NewRecorder() + mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=1", nil)) + if resp.Result().StatusCode != 200 { t.Errorf("Got status %s, expected 200 - OK", resp.Result().Status) } + + if finalizer.id != 1 { + t.Errorf("Finanlizer was called with id %d, expected 1", finalizer.id) + } }) t.Run("Not Exist error", func(t *testing.T) { - clearAller.expectErr = vote.ErrNotExists + finalizer.expectErr = vote.ErrNotExists resp := httptest.NewRecorder() - mux.ServeHTTP(resp, httptest.NewRequest("POST", url, nil)) + mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=1", nil)) if resp.Result().StatusCode != 400 { t.Errorf("Got status %s, expected 400", resp.Result().Status) @@ -301,63 +514,58 @@ func TestHandleClearAll(t *testing.T) { t.Errorf("Got error `%s`, expected `not-exist`", body.Error) } }) -} -type voterStub struct { - id int - user int - body string - expectErr error -} + t.Run("Publish", func(t *testing.T) { + finalizer.expectErr = nil -func (v *voterStub) Vote(ctx context.Context, pollID, requestUser int, r io.Reader) error { - v.id = pollID - v.user = requestUser + resp := httptest.NewRecorder() + mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=1&publish", nil)) - body, err := io.ReadAll(r) - if err != nil { - return err - } - v.body = string(body) - return v.expectErr -} + if resp.Result().StatusCode != 200 { + t.Errorf("Got status %s, expected 200 - OK.\nBody: %s", resp.Result().Status, resp.Body.String()) + } -type AuthError struct{} + if !finalizer.publish { + t.Errorf("Finanlizer was not called with publish") + } + }) -func (AuthError) Error() string { - return `{"error":"auth","message":"auth error"}` -} + t.Run("Anonymize", func(t *testing.T) { + finalizer.expectErr = nil -func (AuthError) Type() string { - return "auth" -} + resp := httptest.NewRecorder() + mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=1&anonymize", nil)) -type autherStub struct { - userID int - authErr bool + if resp.Result().StatusCode != 200 { + t.Errorf("Got status %s, expected 200 - OK.\nBody: %s", resp.Result().Status, resp.Body.String()) + } + + if !finalizer.anonymize { + t.Errorf("Finanlizer was not called with anonymize") + } + }) } -func (a *autherStub) Authenticate(w http.ResponseWriter, r *http.Request) (context.Context, error) { - if a.authErr { - return nil, AuthError{} - } - return r.Context(), nil +type reseterStub struct { + pollID int + requestUserID int + expectErr error } -func (a *autherStub) FromContext(context.Context) int { - return a.userID +func (r *reseterStub) Reset(ctx context.Context, pollID int, requestUserID int) error { + r.pollID = pollID + r.requestUserID = requestUserID + return r.expectErr } -func TestHandleVote(t *testing.T) { - voter := &voterStub{} - auther := &autherStub{} +func TestHandleReset(t *testing.T) { + reseter := &reseterStub{} + auth := &AutherStub{userID: 1} - url := "/system/vote" - mux := handleExternal(handleVote(voter, auther)) + url := "/system/vote/reset" + mux := testresolveError(handleReset(reseter, auth)) t.Run("No id", func(t *testing.T) { - auther.userID = 5 - resp := httptest.NewRecorder() mux.ServeHTTP(resp, httptest.NewRequest("POST", url, nil)) @@ -366,31 +574,34 @@ func TestHandleVote(t *testing.T) { } }) - t.Run("ErrDoubleVote error", func(t *testing.T) { - voter.expectErr = vote.ErrDoubleVote - + t.Run("Invalid id", func(t *testing.T) { resp := httptest.NewRecorder() - mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=1", nil)) + mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=value", nil)) if resp.Result().StatusCode != 400 { - t.Errorf("Got status %s, expected 400", resp.Result().Status) + t.Errorf("Got status %s, expected 400 - Bad Request", resp.Result().Status) } + }) - var body struct { - Error string `json:"error"` + t.Run("Valid", func(t *testing.T) { + resp := httptest.NewRecorder() + mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=1", nil)) + + if resp.Result().StatusCode != 200 { + t.Errorf("Got status %s, expected 200 - OK", resp.Result().Status) } - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { - t.Fatalf("decoding resp body: %v", err) + if reseter.pollID != 1 { + t.Errorf("Reset was called with pollID %d, expected 1", reseter.pollID) } - if body.Error != "double-vote" { - t.Errorf("Got error `%s`, expected `double-vote`", body.Error) + if reseter.requestUserID != 1 { + t.Errorf("Reset was called with userID %d, expected 1", reseter.requestUserID) } }) - t.Run("Auth error", func(t *testing.T) { - auther.authErr = true + t.Run("Error not exist", func(t *testing.T) { + reseter.expectErr = vote.ErrNotExists resp := httptest.NewRecorder() mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=1", nil)) @@ -407,110 +618,105 @@ func TestHandleVote(t *testing.T) { t.Fatalf("decoding resp body: %v", err) } - if body.Error != "auth" { - t.Errorf("Got error `%s`, expected `auth`", body.Error) + if body.Error != "not-exist" { + t.Errorf("Got error `%s`, expected `not-exist`", body.Error) } }) - t.Run("Anonymous", func(t *testing.T) { - auther.userID = 0 - auther.authErr = false + t.Run("Internal error", func(t *testing.T) { + reseter.expectErr = errors.New("test internal error") resp := httptest.NewRecorder() mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=1", nil)) - if resp.Result().StatusCode != 401 { - t.Errorf("Got status %s, expected 401", resp.Result().Status) + if resp.Result().StatusCode != 500 { + t.Errorf("Got status %s, expected 500", resp.Result().Status) } var body struct { Error string `json:"error"` + MSG string `json:"message"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { t.Fatalf("decoding resp body: %v", err) } - if body.Error != "not-allowed" { - t.Errorf("Got error `%s`, expected `auth`", body.Error) - } - }) - - t.Run("Valid", func(t *testing.T) { - auther.userID = 5 - voter.body = "request body" - voter.expectErr = nil - - resp := httptest.NewRecorder() - mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=1", strings.NewReader("request body"))) - - if resp.Result().StatusCode != 200 { - t.Errorf("Got status %s, expected 200 - OK", resp.Result().Status) - } - - if voter.id != 1 { - t.Errorf("Voter was called with id %d, expected 1", voter.id) - } - - if voter.user != 5 { - t.Errorf("Voter was called with userID %d, expected 5", voter.user) + if body.Error != "internal" { + t.Errorf("Got error `%s`, expected `internal`", body.Error) } - if voter.body != "request body" { - t.Errorf("Voter was called with body `%s` expected `request body`", voter.body) + if body.MSG != "Ups, something went wrong!" { + t.Errorf("Got error message `%s`, expected `Ups, something went wrong!`", body.MSG) } }) } -type votederStub struct { - pollIDs []int - user int - expectVote map[int][]int - expectErr error +type voterStub struct { + id int + user int + body string + expectErr error } -func (v *votederStub) Voted(ctx context.Context, pollIDs []int, requestUser int) (map[int][]int, error) { - v.pollIDs = pollIDs - v.user = requestUser +func (v *voterStub) Vote(ctx context.Context, pollID, requestUserID int, r io.Reader) error { + v.id = pollID + v.user = requestUserID - if v.expectErr != nil { - return nil, v.expectErr + body, err := io.ReadAll(r) + if err != nil { + return err } - return v.expectVote, nil + v.body = string(body) + return v.expectErr } -func TestHandleVoted(t *testing.T) { - voted := &votederStub{} - auther := &autherStub{} +func TestHandleVote(t *testing.T) { + voter := &voterStub{} + auther := &AutherStub{} - url := "/system/vote/voted" - mux := handleExternal(handleVoted(voted, auther)) + url := "/system/vote" + mux := testresolveError(handleVote(voter, auther)) - t.Run("No polls given", func(t *testing.T) { + t.Run("No id", func(t *testing.T) { auther.userID = 5 + resp := httptest.NewRecorder() - mux.ServeHTTP(resp, httptest.NewRequest("GET", url, nil)) + mux.ServeHTTP(resp, httptest.NewRequest("POST", url, nil)) if resp.Result().StatusCode != 400 { - t.Errorf("Got status %s, expected 400", resp.Result().Status) + t.Errorf("Got status %s, expected 400 - Bad Request", resp.Result().Status) } }) - t.Run("Wrong polls value", func(t *testing.T) { - auther.userID = 5 + t.Run("ErrDoubleVote error", func(t *testing.T) { + voter.expectErr = vote.ErrDoubleVote + resp := httptest.NewRecorder() - mux.ServeHTTP(resp, httptest.NewRequest("GET", url+"?ids=foo", nil)) + mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=1", nil)) if resp.Result().StatusCode != 400 { t.Errorf("Got status %s, expected 400", resp.Result().Status) } + + var body struct { + Error string `json:"error"` + } + + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decoding resp body: %v", err) + } + + if body.Error != "double-vote" { + t.Errorf("Got error `%s`, expected `double-vote`", body.Error) + } }) t.Run("Auth error", func(t *testing.T) { auther.authErr = true resp := httptest.NewRecorder() - mux.ServeHTTP(resp, httptest.NewRequest("GET", url+"?ids=1", nil)) + mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=1", nil)) if resp.Result().StatusCode != 400 { t.Errorf("Got status %s, expected 400", resp.Result().Status) @@ -530,11 +736,11 @@ func TestHandleVoted(t *testing.T) { }) t.Run("Anonymous", func(t *testing.T) { - auther.authErr = false auther.userID = 0 + auther.authErr = false resp := httptest.NewRecorder() - mux.ServeHTTP(resp, httptest.NewRequest("GET", url+"?ids=1", nil)) + mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=1", nil)) if resp.Result().StatusCode != 401 { t.Errorf("Got status %s, expected 401", resp.Result().Status) @@ -549,226 +755,36 @@ func TestHandleVoted(t *testing.T) { } if body.Error != "not-allowed" { - t.Errorf("Got error `%s`, expected `not-allowed`", body.Error) + t.Errorf("Got error `%s`, expected `auth`", body.Error) } }) - t.Run("Correct", func(t *testing.T) { + t.Run("Valid", func(t *testing.T) { auther.userID = 5 - auther.authErr = false + voter.body = "request body" + voter.expectErr = nil resp := httptest.NewRecorder() - mux.ServeHTTP(resp, httptest.NewRequest("GET", url+"?ids=1,2", nil)) + mux.ServeHTTP(resp, httptest.NewRequest("POST", url+"?id=1", strings.NewReader("request body"))) if resp.Result().StatusCode != 200 { - t.Errorf("Got status %s, expected 200", resp.Result().Status) - } - - if len(voted.pollIDs) != 2 || voted.pollIDs[0] != 1 || voted.pollIDs[1] != 2 { - t.Errorf("Voted was called with pollIDs %v, expected [1,2]", voted.pollIDs) - } - }) - - t.Run("Voted Error", func(t *testing.T) { - auther.userID = 5 - auther.authErr = false - voted.expectErr = vote.ErrNotExists - - resp := httptest.NewRecorder() - mux.ServeHTTP(resp, httptest.NewRequest("GET", url+"?ids=1,2", nil)) - - if resp.Result().StatusCode != 400 { - t.Errorf("Got status %s, expected 400", resp.Result().Status) + t.Errorf("Got status %s, expected 200 - OK", resp.Result().Status) } - var body struct { - Error string `json:"error"` + if voter.id != 1 { + t.Errorf("Voter was called with id %d, expected 1", voter.id) } - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { - t.Fatalf("decoding resp body: %v", err) + if voter.user != 5 { + t.Errorf("Voter was called with userID %d, expected 5", voter.user) } - if body.Error != "not-exist" { - t.Errorf("Got error `%s`, expected `not-exist`", body.Error) + if voter.body != "request body" { + t.Errorf("Voter was called with body `%s` expected `request body`", voter.body) } }) } -type allLiveVotesStub struct { - expectCount map[int]map[int]*string -} - -func (v *allLiveVotesStub) AllLiveVotes(ctx context.Context) map[int]map[int]*string { - return v.expectCount -} - -func TestHandleAllVotedIDs_first_data(t *testing.T) { - voteCounter := &allLiveVotesStub{} - - eventer := func() (<-chan time.Time, func()) { - return make(chan time.Time), func() {} - } - - mux := handleAllVotedIDs(voteCounter, eventer) - - ctx := t.Context() - - url := "/vote/live_votes" - resp := httptest.NewRecorder() - voteStr := "vote" - voteCounter.expectCount = map[int]map[int]*string{1: {1: &voteStr, 2: nil, 3: nil}, 2: {4: nil, 5: nil, 6: nil}} - - // TODO: find a better way then a timeout - reqCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond) - defer cancel() - req, _ := http.NewRequestWithContext(reqCtx, "GET", url, nil) - - mux.ServeHTTP(resp, req) - - if resp.Result().StatusCode != 200 { - t.Fatalf("Got status %s, expected 200", resp.Result().Status) - } - - var got map[int]map[int]*string - if err := json.NewDecoder(resp.Result().Body).Decode(&got); err != nil { - t.Fatalf("decoding: %v", err) - } - - if !reflect.DeepEqual(got, voteCounter.expectCount) { - t.Errorf("Got %v, expected %v", got, voteCounter.expectCount) - } -} - -func TestHandleAllVotedIDs_first_data_empty(t *testing.T) { - voteCounter := &allLiveVotesStub{} - - eventer := func() (<-chan time.Time, func()) { - return make(chan time.Time), func() {} - } - - mux := handleAllVotedIDs(voteCounter, eventer) - - ctx := t.Context() - - url := "/vote/vote_count" - resp := httptest.NewRecorder() - voteCounter.expectCount = map[int]map[int]*string{} - - // TODO: find a better way then a timeout - reqCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond) - defer cancel() - req, _ := http.NewRequestWithContext(reqCtx, "GET", url, nil) - mux.ServeHTTP(resp, req) - - if resp.Result().StatusCode != 200 { - t.Fatalf("Got status %s, expected 200", resp.Result().Status) - } - - var got map[int]map[int]*string - if err := json.NewDecoder(resp.Result().Body).Decode(&got); err != nil { - t.Fatalf("decoding: %v", err) - } - - if !reflect.DeepEqual(got, voteCounter.expectCount) { - t.Errorf("Got %v, expected %v", got, voteCounter.expectCount) - } -} - -func TestHandleAllVotedIDs_second_data(t *testing.T) { - voteCounter := &allLiveVotesStub{} - - event := make(chan time.Time, 1) - eventer := func() (<-chan time.Time, func()) { - return event, func() {} - } - - mux := handleAllVotedIDs(voteCounter, eventer) - - ctx := context.Background() - - vote1Str := "vote" - vote2Str := "vote2" - - data := []map[int]map[int]*string{ - {1: {1: nil, 2: nil}, 2: {20: &vote1Str}}, - {1: {1: nil, 2: nil, 3: nil}, 2: {20: &vote1Str}}, // Change only 1 - {1: {1: nil, 2: nil, 3: nil}, 2: {20: &vote1Str}}, // No Change - {1: {1: nil, 2: nil, 3: nil}}, // Remove 2 - {1: {1: nil, 2: nil, 3: nil}, 3: {30: &vote2Str}}, // Add 3 - {1: {1: nil, 2: nil, 3: nil}}, // Remove 3 (that was not there at the beginning) - } - - url := "/vote/vote_count" - resp := httptest.NewRecorder() - - // TODO: find a better way then a timeout - reqCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond) - defer cancel() - req, _ := http.NewRequestWithContext(reqCtx, "GET", url, nil) - - voteCounter.expectCount = data[0] - i := 0 - flushResp := onFlush{resp, func() { - i++ - if i >= len(data) { - close(event) - return - } - voteCounter.expectCount = data[i] - event <- time.Now() - }} - - mux.ServeHTTP(flushResp, req) - - if resp.Result().StatusCode != 200 { - t.Fatalf("Got status %s, expected 200", resp.Result().Status) - } - - expect := []map[int]map[int]string{ - {1: {1: "", 2: ""}, 2: {20: "vote"}}, - {1: {3: ""}}, - {2: nil}, - {3: {30: "vote2"}}, - {3: nil}, - } - - decoder := json.NewDecoder(resp.Body) - for i := range expect { - var got map[int]map[int]*string - if err := decoder.Decode(&got); err != nil { - if err == io.EOF { - t.Errorf("Got %d packages, expected %d", i, len(expect)) - break - } - t.Fatalf("decoding: %v", err) - } - - if !reflect.DeepEqual(resolvePointers(got), expect[i]) { - t.Errorf("Data %d: Got %v, expected %v", i+1, got, expect[i]) - } - } -} - -func resolvePointers(in map[int]map[int]*string) map[int]map[int]string { - out := make(map[int]map[int]string) - for pollID, user2Vote := range in { - if user2Vote == nil { - out[pollID] = nil - continue - } - out[pollID] = make(map[int]string) - for userID, vote := range user2Vote { - if vote == nil { - out[pollID][userID] = "" - continue - } - out[pollID][userID] = *vote - } - } - return out -} - func TestHandleHealth(t *testing.T) { url := "/system/vote/health" mux := handleHealth() @@ -786,14 +802,31 @@ func TestHandleHealth(t *testing.T) { } } -type onFlush struct { - http.ResponseWriter - f func() +type AuthError struct{} + +func (AuthError) Error() string { + return `auth error` } -func (f onFlush) Flush() { - f.f() - if flusher, ok := f.ResponseWriter.(http.Flusher); ok { - flusher.Flush() +func (AuthError) Type() string { + return "auth" +} + +// AutherSub fakes auth +type AutherStub struct { + userID int + authErr bool +} + +func (a *AutherStub) Authenticate(w http.ResponseWriter, r *http.Request) (context.Context, error) { + if a.authErr { + return nil, AuthError{} } + return r.Context(), nil } + +func (a *AutherStub) FromContext(context.Context) int { + return a.userID +} + +var testresolveError = getResolveError(func(fmt string, a ...any) (int, error) { return 0, nil }) diff --git a/vote/methods.go b/vote/methods.go new file mode 100644 index 00000000..a97dbf98 --- /dev/null +++ b/vote/methods.go @@ -0,0 +1,395 @@ +package vote + +import ( + "encoding/json" + "errors" + "fmt" + "slices" + "strconv" + "strings" + + "github.com/OpenSlides/openslides-go/datastore/dsfetch" + "github.com/OpenSlides/openslides-go/datastore/dsmodels" + "github.com/shopspring/decimal" +) + +const ( + keyAbstain = "abstain" + keyNota = "nota" + keyInvalid = "invalid" +) + +var reservedOptionNames = []string{keyAbstain, keyNota, keyInvalid} + +type method interface { + Name() string + ValidateVote(config string, vote json.RawMessage) error + Result(config string, votes []dsmodels.Ballot) (string, error) +} + +type methodApprovalConfig struct { + AllowAbstain dsfetch.Maybe[bool] `json:"allow_abstain"` +} + +type methodApproval struct{} + +func (m methodApproval) Name() string { + return "approval" +} + +func (m methodApproval) ValidateVote(config string, vote json.RawMessage) error { + var cfg methodApprovalConfig + + if config != "" { + if err := json.Unmarshal([]byte(config), &cfg); err != nil { + return fmt.Errorf("invalid configuration: %w", err) + } + } + + switch strings.ToLower(string(vote)) { + case `"yes"`, `"no"`: + return nil + case `"abstain"`: + if abstain, set := cfg.AllowAbstain.Value(); !abstain && set { + return invalidVote("abstain disabled") + } + return nil + default: + return invalidVote("Unknown value %s", vote) + } +} + +func (m methodApproval) Result(config string, votes []dsmodels.Ballot) (string, error) { + return iterateValues(m, config, votes, func(value string, weight decimal.Decimal, result map[string]decimal.Decimal) error { + switch strings.ToLower(value) { + case `"yes"`: + result["yes"] = result["yes"].Add(weight) + case `"no"`: + result["no"] = result["no"].Add(weight) + case `"abstain"`: + result["abstain"] = result["abstain"].Add(weight) + } + return nil + }) +} + +type methodSelectionConfig struct { + Options []int `json:"options"` + MaxOptionsAmount dsfetch.Maybe[int] `json:"max_options_amount"` + MinOptionsAmount dsfetch.Maybe[int] `json:"min_options_amount"` + AllowNota bool `json:"allow_nota"` +} + +type methodSelection struct{} + +func (m methodSelection) Name() string { + return "selection" +} + +func (m methodSelection) ValidateVote(config string, vote json.RawMessage) error { + var cfg methodSelectionConfig + + if err := json.Unmarshal([]byte(config), &cfg); err != nil { + return fmt.Errorf("invalid configuration: %w", err) + } + + var choice []int + if err := json.Unmarshal(vote, &choice); err != nil { + if cfg.AllowNota && strings.ToLower(string(vote)) == `"nota"` { + return nil + } + return errors.Join(invalidVote("Vote has invalid format"), fmt.Errorf("decoding vote: %w", err)) + } + + if hasDuplicates(choice) { + return invalidVote("douplicate entries in vote") + } + + if value, set := cfg.MaxOptionsAmount.Value(); set && len(choice) > value { + return invalidVote("too many options") + } + + if value, set := cfg.MinOptionsAmount.Value(); set && len(choice) < value { + return invalidVote("too few options") + } + for _, option := range choice { + if !slices.Contains(cfg.Options, option) { + return invalidVote("unknown option id %d", option) + } + } + + return nil +} + +func (m methodSelection) Result(config string, votes []dsmodels.Ballot) (string, error) { + var cfg methodSelectionConfig + if err := json.Unmarshal([]byte(config), &cfg); err != nil { + return "", fmt.Errorf("invalid configuration: %w", err) + } + + return iterateValues(m, config, votes, func(value string, weight decimal.Decimal, result map[string]decimal.Decimal) error { + var votedOptions []int + if err := json.Unmarshal([]byte(value), &votedOptions); err != nil { + if cfg.AllowNota && strings.ToLower(value) == `"nota"` { + result[keyNota] = result[keyNota].Add(weight) + return nil + } + return fmt.Errorf("invalid options `%s`: %w", value, err) + } + + for _, votedOption := range votedOptions { + result[strconv.Itoa(votedOption)] = result[strconv.Itoa(votedOption)].Add(weight) + } + + if len(votedOptions) == 0 { + result[keyAbstain] = result[keyAbstain].Add(weight) + } + + return nil + }) +} + +type methodRatingScoreConfig struct { + Options []int `json:"options"` + MaxOptionsAmount dsfetch.Maybe[int] `json:"max_options_amount"` + MinOptionsAmount dsfetch.Maybe[int] `json:"min_options_amount"` + MaxVotesPerOption dsfetch.Maybe[int] `json:"max_votes_per_option"` + MaxVoteSum dsfetch.Maybe[int] `json:"max_vote_sum"` + MinVoteSum dsfetch.Maybe[int] `json:"min_vote_sum"` +} + +type methodRatingScore struct{} + +func (m methodRatingScore) Name() string { + return "rating-score" +} + +func (m methodRatingScore) ValidateVote(config string, vote json.RawMessage) error { + var cfg methodRatingScoreConfig + + if err := json.Unmarshal([]byte(config), &cfg); err != nil { + return fmt.Errorf("invalid configuration: %w", err) + } + + var choice map[int]int + if err := json.Unmarshal(vote, &choice); err != nil { + return errors.Join(invalidVote("Vote has invalid format"), fmt.Errorf("decoding vote: %w", err)) + } + + if value, set := cfg.MaxOptionsAmount.Value(); set && len(choice) > value { + return invalidVote("too many options") + } + + if value, set := cfg.MinOptionsAmount.Value(); set && len(choice) < value { + return invalidVote("too few options") + } + + var sum int + for option, choice := range choice { + if !slices.Contains(cfg.Options, option) { + return invalidVote("unknown option id %d", option) + } + + if choice < 0 { + return invalidVote("negative value for option") + } + + if value, set := cfg.MaxVotesPerOption.Value(); set { + if choice > value { + return invalidVote("too many votes for option") + } + } + sum += choice + } + + if value, set := cfg.MaxVoteSum.Value(); set && sum > value { + return invalidVote("too many votes") + } + + if value, set := cfg.MinVoteSum.Value(); set && sum < value { + return invalidVote("too few votes") + } + + return nil +} + +func (m methodRatingScore) Result(config string, votes []dsmodels.Ballot) (string, error) { + return iterateValues(m, config, votes, func(value string, weight decimal.Decimal, result map[string]decimal.Decimal) error { + var votedOptions map[string]int + if err := json.Unmarshal([]byte(value), &votedOptions); err != nil { + return fmt.Errorf("invalid options `%s`: %w", value, err) + } + + for votedOption, value := range votedOptions { + voteWithFactor := weight.Mul(decimal.NewFromInt(int64(value))) + result[votedOption] = result[votedOption].Add(voteWithFactor) + } + + if len(votedOptions) == 0 { + result[keyAbstain] = result[keyAbstain].Add(weight) + } + + return nil + }) +} + +type methodRatingApprovalConfig struct { + Options []int `json:"options"` + MaxOptionsAmount dsfetch.Maybe[int] `json:"max_options_amount"` + MinOptionsAmount dsfetch.Maybe[int] `json:"min_options_amount"` + AllowAbstain dsfetch.Maybe[bool] `json:"allow_abstain"` +} + +type methodRatingApproval struct{} + +func (m methodRatingApproval) Name() string { + return "rating-approval" +} + +func (m methodRatingApproval) ValidateVote(config string, vote json.RawMessage) error { + var cfg methodRatingApprovalConfig + + if err := json.Unmarshal([]byte(config), &cfg); err != nil { + return fmt.Errorf("invalid configuration: %w", err) + } + + var choice map[int]json.RawMessage + if err := json.Unmarshal(vote, &choice); err != nil { + return errors.Join(invalidVote("Vote has invalid format"), fmt.Errorf("decoding vote: %w", err)) + } + + if value, set := cfg.MaxOptionsAmount.Value(); set && len(choice) > value { + return invalidVote("too many options") + } + + if value, set := cfg.MinOptionsAmount.Value(); set && len(choice) < value { + return invalidVote("too few options") + } + + for option, choice := range choice { + if !slices.Contains(cfg.Options, option) { + return invalidVote("unknown option id %d", option) + } + + if err := (methodApproval{}).ValidateVote(config, choice); err != nil { + return fmt.Errorf("validating option id %d: %w", option, err) + } + } + + return nil +} + +func (m methodRatingApproval) Result(config string, votes []dsmodels.Ballot) (string, error) { + result := make(map[string]map[string]decimal.Decimal) + invalid := 0 + + for _, vote := range votes { + if err := m.ValidateVote(config, json.RawMessage(vote.Value)); err != nil { + if errors.Is(err, ErrInvalid) { + invalid++ + continue + } + return "", fmt.Errorf("validating vote: %w", err) + } + + weight := vote.Weight + if vote.Weight.IsZero() { + weight = decimal.NewFromInt(1) + } + + var votedOptions map[string]json.RawMessage + if err := json.Unmarshal([]byte(vote.Value), &votedOptions); err != nil { + return "", fmt.Errorf("invalid options `%s`: %w", vote.Value, err) + } + + for option, value := range votedOptions { + if _, ok := result[option]; !ok { + result[option] = make(map[string]decimal.Decimal) + } + + switch strings.ToLower(string(value)) { + case `"yes"`: + result[option]["yes"] = result[option]["yes"].Add(weight) + case `"no"`: + result[option]["no"] = result[option]["no"].Add(weight) + case `"abstain"`: + result[option]["abstain"] = result[option]["abstain"].Add(weight) + } + } + } + + encodedResult, err := json.Marshal(result) + if err != nil { + return "", fmt.Errorf("encode result: %w", err) + } + withInvalid, err := addInvalid(encodedResult, invalid) + if err != nil { + return "", fmt.Errorf("add invalid: %w", err) + } + return string(withInvalid), nil +} + +func addInvalid(result []byte, invalid int) ([]byte, error) { + if invalid == 0 { + return result, nil + } + + var data map[string]any + if err := json.Unmarshal(result, &data); err != nil { + return nil, err + } + + data[keyInvalid] = invalid + + return json.Marshal(data) +} + +func iterateValues( + m method, + config string, + votes []dsmodels.Ballot, + fn func(value string, weight decimal.Decimal, result map[string]decimal.Decimal) error, +) (string, error) { + result := make(map[string]decimal.Decimal) + invalid := 0 + for _, vote := range votes { + if err := m.ValidateVote(config, json.RawMessage(vote.Value)); err != nil { + if errors.Is(err, ErrInvalid) { + invalid++ + continue + } + return "", fmt.Errorf("validating vote: %w", err) + } + + factor := vote.Weight + if factor.IsZero() { + factor = decimal.NewFromInt(1) + } + + if err := fn(vote.Value, factor, result); err != nil { + return "", fmt.Errorf("prcess: %w", err) + } + } + + encodedResult, err := json.Marshal(result) + if err != nil { + return "", fmt.Errorf("encode result: %w", err) + } + + withInvalid, err := addInvalid(encodedResult, invalid) + if err != nil { + return "", fmt.Errorf("add invalid: %w", err) + } + return string(withInvalid), nil +} + +func hasDuplicates[T comparable](slice []T) bool { + seen := make(map[T]struct{}, len(slice)) + for _, v := range slice { + if _, ok := seen[v]; ok { + return true + } + seen[v] = struct{}{} + } + return false +} diff --git a/vote/methods_test.go b/vote/methods_test.go new file mode 100644 index 00000000..575123e6 --- /dev/null +++ b/vote/methods_test.go @@ -0,0 +1,407 @@ +package vote_test + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/OpenSlides/openslides-go/datastore/dsmodels" + "github.com/OpenSlides/openslides-vote-service/vote" + "github.com/shopspring/decimal" +) + +func TestValidateVote(t *testing.T) { + for _, tt := range []struct { + name string + method string + config string + vote string + expectValid bool + }{ + { + name: "Approval: Vote Yes", + method: "approval", + config: "", + vote: `"Yes"`, + expectValid: true, + }, + { + name: "Approval: unknown string", + method: "approval", + config: "", + vote: `"Y"`, + expectValid: false, + }, + { + name: "Approval: Abstain", + method: "approval", + config: "", + vote: `"Abstain"`, + expectValid: true, + }, + { + name: "Approval: Abstain deactivated", + method: "approval", + config: `{"allow_abstain": false}`, + vote: `"Abstain"`, + expectValid: false, + }, + { + name: "Selection invalid json", + method: "selection", + config: `{"options":[1,2]}`, + vote: `[0`, + expectValid: false, + }, + { + name: "Selection", + method: "selection", + config: `{"options":[1,2]}`, + vote: `[1]`, + expectValid: true, + }, + { + name: "Selection same value multiple times", + method: "selection", + config: `{"options":[1,2]}`, + vote: `[1,1]`, + expectValid: false, + }, + { + name: "Selection unknown key", + method: "selection", + config: `{"options":[1,2]}`, + vote: `[3]`, + expectValid: false, + }, + { + name: "Selection max_options_amount", + method: "selection", + config: `{"options":[1,2],"max_options_amount":1}`, + vote: `[1]`, + expectValid: true, + }, + { + name: "Selection max_options_amount too many", + method: "selection", + config: `{"options":[1,2],"max_options_amount":1}`, + vote: `[1,2]`, + expectValid: false, + }, + { + name: "Selection min_options_amount", + method: "selection", + config: `{"options":[1,2],"min_options_amount":1}`, + vote: `[1]`, + expectValid: true, + }, + { + name: "Selection min_options_amount too few", + method: "selection", + config: `{"options":[1,2],"min_options_amount":2}`, + vote: `[1]`, + expectValid: false, + }, + { + name: "Selection nota", + method: "selection", + config: `{"options":[1,2],"min_options_amount":2,"allow_nota":true}`, + vote: `"nota"`, + expectValid: true, + }, + { + name: "Rating-Score", + method: "rating-score", + config: `{"options":[1,2]}`, + vote: `{"1":3}`, + expectValid: true, + }, + { + name: "Rating-Score invalid key", + method: "rating-score", + config: `{"options":[1,2]}`, + vote: `{"0":3}`, + expectValid: false, + }, + { + name: "Rating-Score with negative value", + method: "rating-score", + config: `{"options":[1,2]}`, + vote: `{"1":-3}`, + expectValid: false, + }, + { + name: "Rating-Score max_options_amount", + method: "rating-score", + config: `{"options":[1,2],"max_options_amount":1}`, + vote: `{"1":3}`, + expectValid: true, + }, + { + name: "Rating-Score max_options_amount too many", + method: "rating-score", + config: `{"options":[1,2],"max_options_amount":1}`, + vote: `{"1":3, "2":1}`, + expectValid: false, + }, + { + name: "Rating-Score min_options_amount", + method: "rating-score", + config: `{"options":[1,2],"min_options_amount":1}`, + vote: `{"1":3}`, + expectValid: true, + }, + { + name: "Rating-Score min_options_amount too few", + method: "rating-score", + config: `{"options":[1,2],"min_options_amount":2}`, + vote: `{"1":3}`, + expectValid: false, + }, + { + name: "Rating-Score max_votes_per_option", + method: "rating-score", + config: `{"options":[1,2],"max_votes_per_option":2}`, + vote: `{"1":2}`, + expectValid: true, + }, + { + name: "Rating-Score max_votes_per_option too many", + method: "rating-score", + config: `{"options":[1,2],"max_votes_per_option":2}`, + vote: `{"1":3}`, + expectValid: false, + }, + { + name: "Rating-Score max_vote_sum", + method: "rating-score", + config: `{"options":[1,2],"max_vote_sum":5}`, + vote: `{"1":3}`, + expectValid: true, + }, + { + name: "Rating-Score max_vote_sum too many", + method: "rating-score", + config: `{"options":[1,2],"max_vote_sum":5}`, + vote: `{"1":6}`, + expectValid: false, + }, + { + name: "Rating-Score max_vote_sum too many on different options", + method: "rating-score", + config: `{"options":[1,2],"max_vote_sum":5}`, + vote: `{"1":3, "2":3}`, + expectValid: false, + }, + { + name: "Rating-Score min_vote_sum on one vote", + method: "rating-score", + config: `{"options":[1,2],"min_vote_sum":10}`, + vote: `{"1":5}`, + expectValid: false, + }, + { + name: "Rating-Score min_vote_sum on many votes", + method: "rating-score", + config: `{"options":[1,2],"min_vote_sum":10}`, + vote: `{"1":5, "2":4}`, + expectValid: false, + }, + { + name: "Rating-Score min_vote_sum enough", + method: "rating-score", + config: `{"options":[1,2],"min_vote_sum":1}`, + vote: `{"1":5, "2":5}`, + expectValid: true, + }, + { + name: "Rating-Approval", + method: "rating-approval", + config: `{"options":[1,2]}`, + vote: `{"1":"Yes", "2":"No"}`, + expectValid: true, + }, + { + name: "Rating-Approval invalid key", + method: "rating-approval", + config: `{"options":[1,2]}`, + vote: `{"0":"Yes", "2":"No"}`, + expectValid: false, + }, + { + name: "Rating-Approval disallow abstain", + method: "rating-approval", + config: `{"options":[1,2],"allow_abstain":false}`, + vote: `{"1":"Yes", "2":"Abstain"}`, + expectValid: false, + }, + } { + t.Run(tt.name, func(t *testing.T) { + err := vote.ValidateBallot(tt.method, tt.config, json.RawMessage(tt.vote)) + + if err != nil { + if !errors.Is(err, vote.ErrInvalid) { + t.Errorf("Got unexpected error: %v", err) + } + } + + if tt.expectValid { + if err != nil { + t.Fatalf("Validate returned unexpected error: %v", err) + } + return + } + + if err == nil { + t.Fatalf("Got no validation error") + } + }) + } +} + +func TestCreateResult(t *testing.T) { + for _, tt := range []struct { + name string + method string + config string + allowSplit bool + votes []dsmodels.Ballot + expectResult string + }{ + { + name: "Approval", + method: "approval", + config: "", + votes: []dsmodels.Ballot{ + {Value: `"Yes"`}, + {Value: `"Yes"`}, + {Value: `"No"`}, + }, + expectResult: `{"no":"1","yes":"2"}`, + }, + { + name: "Approval with invalid", + method: "approval", + config: "", + votes: []dsmodels.Ballot{ + {Value: `"Yes"`}, + {Value: `"Yes"`}, + {Value: `"No"`}, + {Value: `"ABC"`}, + }, + expectResult: `{"invalid":1,"no":"1","yes":"2"}`, + }, + { + name: "Approval with split", + method: "approval", + config: "", + allowSplit: true, + votes: []dsmodels.Ballot{ + {Value: `{"0.3":"Yes","0.7":"No"}`, Split: true, Weight: decimal.NewFromInt(1)}, // valid + {Value: `{"0.3":"Yes","0.7":"No"}`, Split: false, Weight: decimal.NewFromInt(1)}, // split not set + {Value: `{"1.3":"Yes","1.7":"No"}`, Split: true, Weight: decimal.NewFromInt(1)}, // Vote weight is too hight + {Value: `{"0.3":"Yes","0.7":"ABC"}`, Split: true, Weight: decimal.NewFromInt(1)}, // One vote is invalid + }, + expectResult: `{"invalid":3,"no":"0.7","yes":"0.3"}`, + }, + { + name: "Approval with split not enabled", + method: "approval", + config: "", + allowSplit: false, + votes: []dsmodels.Ballot{ + {Value: `{"0.3":"Yes","0.7":"No"}`, Split: true, Weight: decimal.NewFromInt(1)}, + {Value: `{"0.3":"Yes","0.7":"No"}`, Split: false, Weight: decimal.NewFromInt(1)}, + {Value: `{"1.3":"Yes","1.7":"No"}`, Split: true, Weight: decimal.NewFromInt(1)}, + }, + expectResult: `{"invalid":3}`, + }, + { + name: "Selection", + method: "selection", + config: `{"options":[1,2,3]}`, + votes: []dsmodels.Ballot{ + {Value: `[1,2]`}, + {Value: `[2,3]`}, + {Value: `[3]`, Weight: decimal.NewFromInt(5)}, + }, + expectResult: `{"1":"1","2":"2","3":"6"}`, + }, + { + name: "Selection abstain", + method: "selection", + config: `{"options":[1,2,3]}`, + votes: []dsmodels.Ballot{ + {Value: `[1,2]`}, + {Value: `[]`}, + {Value: `[]`, Weight: decimal.NewFromInt(5)}, + }, + expectResult: `{"1":"1","2":"1","abstain":"6"}`, + }, + { + name: "Selection nota", + method: "selection", + config: `{"options":[1,2,3],"allow_nota":true}`, + votes: []dsmodels.Ballot{ + {Value: `[1,2]`}, + {Value: `"nota"`}, + {Value: `"nota"`, Weight: decimal.NewFromInt(5)}, + }, + expectResult: `{"1":"1","2":"1","nota":"6"}`, + }, + { + name: "Rating-Score", + method: "rating-score", + config: `{"options":[1,2,3]}`, + votes: []dsmodels.Ballot{ + {Value: `{"1":3,"2":3}`}, + {Value: `{"2":2,"3":3}`}, + {Value: `{"3":5}`, Weight: decimal.NewFromInt(5)}, + }, + expectResult: `{"1":"3","2":"5","3":"28"}`, + }, + { + name: "Rating-Score Abstain", + method: "rating-score", + config: `{"options":[1,2,3]}`, + votes: []dsmodels.Ballot{ + {Value: `{"1":3,"2":3}`}, + {Value: `{}`}, + {Value: `{}`, Weight: decimal.NewFromInt(5)}, + }, + expectResult: `{"1":"3","2":"3","abstain":"6"}`, + }, + { + name: "Rating-Approval", + method: "rating-approval", + config: `{"options":[1,2,3]}`, + votes: []dsmodels.Ballot{ + {Value: `{"1":"yes","2":"no"}`}, + {Value: `{"2":"yes","3":"no"}`}, + {Value: `{"3":"yes"}`, Weight: decimal.NewFromInt(5)}, + }, + expectResult: `{"1":{"yes":"1"},"2":{"no":"1","yes":"1"},"3":{"no":"1","yes":"5"}}`, + }, + { + name: "Rating-Approval with out abstain but with invalid", + method: "rating-approval", + config: `{"options":[1,2,3],"allow_abstain":false}`, + votes: []dsmodels.Ballot{ + {Value: `{"1":"yes","2":"abstain"}`}, + {Value: `{"1":"yes","2":"no"}`}, + }, + expectResult: `{"1":{"yes":"1"},"2":{"no":"1"},"invalid":1}`, + }, + } { + t.Run(tt.name, func(t *testing.T) { + result, err := vote.CreateResult(tt.method, tt.config, tt.allowSplit, tt.votes) + if err != nil { + t.Fatalf("CreateResult: %v", err) + } + + if string(result) != tt.expectResult { + t.Errorf("Got: %s, expected %s", result, tt.expectResult) + } + }) + } +} diff --git a/vote/mock_test.go b/vote/mock_test.go deleted file mode 100644 index ad3a8e7f..00000000 --- a/vote/mock_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package vote_test - -import ( - "context" - - "github.com/OpenSlides/openslides-go/datastore/dskey" -) - -type StubGetter struct { - data map[dskey.Key][]byte - err error - requested map[dskey.Key]bool -} - -func (g *StubGetter) Get(ctx context.Context, keys ...dskey.Key) (map[dskey.Key][]byte, error) { - if g.err != nil { - return nil, g.err - } - if g.requested == nil { - g.requested = make(map[dskey.Key]bool) - } - - out := make(map[dskey.Key][]byte, len(keys)) - for _, k := range keys { - out[k] = g.data[k] - g.requested[k] = true - } - return out, nil -} - -func (g *StubGetter) Update(context.Context, func(map[dskey.Key][]byte, error)) {} diff --git a/vote/vote.go b/vote/vote.go index 58170cb7..bcd551a1 100644 --- a/vote/vote.go +++ b/vote/vote.go @@ -6,15 +6,17 @@ import ( "errors" "fmt" "io" - "maps" - "sync" - "time" + "slices" + "strconv" + "strings" "github.com/OpenSlides/openslides-go/datastore/dsfetch" + "github.com/OpenSlides/openslides-go/datastore/dskey" "github.com/OpenSlides/openslides-go/datastore/dsmodels" - "github.com/OpenSlides/openslides-go/datastore/dsrecorder" "github.com/OpenSlides/openslides-go/datastore/flow" - "github.com/OpenSlides/openslides-vote-service/log" + "github.com/OpenSlides/openslides-go/perm" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/shopspring/decimal" ) @@ -22,851 +24,1430 @@ import ( // // Vote has to be initializes with vote.New(). type Vote struct { - fastBackend Backend - longBackend Backend - flow flow.Flow - - liveVotesMu sync.Mutex - liveVotes map[int]map[int][]byte // voted holds for all running polls, the votes of a user + flow flow.Flow + querier DBQuerier } // New creates an initializes vote service. -func New(ctx context.Context, fast, long Backend, flow flow.Flow, singleInstance bool) (*Vote, func(context.Context, func(error)), error) { +func New(ctx context.Context, flow flow.Flow, querier DBQuerier) (*Vote, func(context.Context, func(error)), error) { v := &Vote{ - fastBackend: fast, - longBackend: long, - flow: flow, - } - - if err := v.loadVoted(ctx); err != nil { - return nil, nil, fmt.Errorf("loading voted: %w", err) + flow: flow, + querier: querier, } bg := func(ctx context.Context, errorHandler func(error)) { - go v.flow.Update(ctx, nil) - - if singleInstance { - return - } + v.flow.Update(ctx, func(changedData map[dskey.Key][]byte, err error) { + if err != nil { + errorHandler(err) + } - go func() { - for { - if err := v.loadVoted(ctx); err != nil { - errorHandler(err) + // This listens on the message bus to see, if a poll got started. If + // so, it preloads its data. This is only relevant, if a poll gets + // started on another instance. + for key, value := range changedData { + if key.CollectionField() == "poll/state" && string(value) == `"started"` { + poll, err := dsmodels.New(v.flow).Poll(key.ID()).First(ctx) + if err != nil { + errorHandler(fmt.Errorf("Error fetching poll for preload: %w", err)) + continue + } + if err := Preload(ctx, dsfetch.New(v.flow), poll.ID, poll.MeetingID); err != nil { + errorHandler(fmt.Errorf("Error preloading poll: %w", err)) + continue + } } - time.Sleep(time.Second) } - }() + }) } return v, bg, nil } -// backend returns the poll backend for a pollConfig object. -func (v *Vote) backend(p dsmodels.Poll) Backend { - backend := v.longBackend - if p.Backend == "fast" { - backend = v.fastBackend +// Create create a poll, returning the poll id. +func (v *Vote) Create(ctx context.Context, requestUserID int, r io.Reader) (int, error) { + electronicVotingEnabled, err := dsfetch.New(v.flow).Organization_EnableElectronicVoting(1).Value(ctx) + if err != nil { + return 0, fmt.Errorf("fetch organization/1/enable_electronic_voting: %w", err) } - log.Debug("Used backend: %v", backend) - return backend -} - -// Start an electronic vote. -// -// This function is idempotence. If you call it with the same input, you will -// get the same output. This means, that when a poll is stopped, Start() will -// not throw an error. -func (v *Vote) Start(ctx context.Context, pollID int) error { - recorder := dsrecorder.New(v.flow) - ds := dsmodels.New(recorder) - poll, err := ds.Poll(pollID).First(ctx) + ci, err := parseCreateInput(r, electronicVotingEnabled) if err != nil { - var doesNotExist dsfetch.DoesNotExistError - if errors.As(err, &doesNotExist) { - return MessageErrorf(ErrNotExists, "Poll %d does not exist", pollID) - } - return fmt.Errorf("loading poll: %w", err) + return 0, fmt.Errorf("parsing input: %w", err) } - if poll.Type == "analog" { - return MessageError(ErrInvalid, "Analog poll can not be started") + if err := canManagePoll(ctx, v.flow, ci.MeetingID, ci.ContentObjectID, requestUserID); err != nil { + return 0, fmt.Errorf("check permissions: %w", err) } - if err := preload(ctx, &ds.Fetch, poll); err != nil { - return fmt.Errorf("preloading data: %w", err) - } - log.Debug("Preload cache. Received keys: %v", recorder.Keys()) + tx, err := v.querier.Begin(ctx) + if err != nil { + return 0, fmt.Errorf("begin transaction: %w", err) + } + defer tx.Rollback(ctx) + + state := "created" + if ci.Visibility == "manually" { + state = "finished" + } + + sql := `INSERT INTO poll + (title, config_id, visibility, state, content_object_id, meeting_id, result, published, allow_vote_split) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id;` + + var newID int + if err := tx.QueryRow( + ctx, + sql, + ci.Title, + // Temporaty value to make postgres happy. Will be set later. This + // workaound can be removed, once this is fixed: + // https://github.com/OpenSlides/openslides-meta/issues/339 + "poll_config_approval/1", + ci.Visibility, + state, + ci.ContentObjectID, + ci.MeetingID, + string(ci.Result), + ci.Published, + ci.AllowVoteSplit, + ).Scan(&newID); err != nil { + return 0, fmt.Errorf("save poll: %w", err) + } + + if err := saveConfig(ctx, tx, newID, ci.Method, ci.Config); err != nil { + return 0, fmt.Errorf("save poll config: %w", err) + } + + if len(ci.EntitledGroupIDs) > 0 { + placeholders := make([]string, len(ci.EntitledGroupIDs)) + args := make([]any, len(ci.EntitledGroupIDs)*2) + + for i, groupID := range ci.EntitledGroupIDs { + placeholders[i] = fmt.Sprintf("($%d, $%d)", i*2+1, i*2+2) + args[i*2] = groupID + args[i*2+1] = newID + } + + groupSQL := fmt.Sprintf( + "INSERT INTO nm_group_poll_ids_poll_t (group_id, poll_id) VALUES %s", + strings.Join(placeholders, ", "), + ) - backend := v.backend(poll) - if err := backend.Start(ctx, pollID); err != nil { - return fmt.Errorf("starting poll in the backend: %w", err) + if _, err := tx.Exec(ctx, groupSQL, args...); err != nil { + return 0, fmt.Errorf("insert group-poll relations: %w", err) + } } - return nil -} + if err := tx.Commit(ctx); err != nil { + return 0, fmt.Errorf("commit transaction: %w", err) + } -// StopResult is the return value from vote.Stop. -type StopResult struct { - Votes [][]byte - UserIDs []int + return newID, nil } -// Stop ends a poll. -// -// This method is idempotence. Many requests with the same pollID will return -// the same data. Calling vote.Clear will stop this behavior. -func (v *Vote) Stop(ctx context.Context, pollID int) (StopResult, error) { - ds := dsmodels.New(v.flow) - poll, err := ds.Poll(pollID).First(ctx) - if err != nil { - var doesNotExist dsfetch.DoesNotExistError - if errors.As(err, &doesNotExist) { - return StopResult{}, MessageErrorf(ErrNotExists, "Poll %d does not exist", pollID) +func saveConfig(ctx context.Context, tx pgx.Tx, pollID int, method string, config json.RawMessage) error { + deleteStatements := []string{ + `DELETE FROM poll_config_approval WHERE poll_id = $1`, + `DELETE FROM poll_config_selection WHERE poll_id = $1`, + `DELETE FROM poll_config_rating_score WHERE poll_id = $1`, + `DELETE FROM poll_config_rating_approval WHERE poll_id = $1`, + } + for _, sql := range deleteStatements { + if _, err := tx.Exec(ctx, sql, pollID); err != nil { + return fmt.Errorf("remove old config entries for poll %d: %w", pollID, err) } - return StopResult{}, fmt.Errorf("loading poll: %w", err) } - backend := v.backend(poll) - ballots, userIDs, err := backend.Stop(ctx, pollID) - if err != nil { - var errNotExist interface{ DoesNotExist() } - if errors.As(err, &errNotExist) { - return StopResult{}, MessageErrorf(ErrNotExists, "Poll %d does not exist in the backend", pollID) + var configObjectID string + optionsRequired := false + switch method { + case "approval": + var cfg methodApprovalConfig + if err := json.Unmarshal(config, &cfg); err != nil { + return fmt.Errorf("parsing approval config: %w", err) } - return StopResult{}, fmt.Errorf("fetching vote objects: %w", err) - } + allowAbstain, set := cfg.AllowAbstain.Value() + if !set { + allowAbstain = true + } - return StopResult{ballots, userIDs}, nil -} + var configID int + sql := `INSERT INTO poll_config_approval (poll_id, allow_abstain) VALUES ($1, $2) RETURNING id;` + if err := tx.QueryRow(ctx, sql, pollID, allowAbstain).Scan(&configID); err != nil { + return fmt.Errorf("save approval config: %w", err) + } + + configObjectID = fmt.Sprintf("poll_config_approval/%d", configID) + optionsRequired = false + + case "selection": + var cfg struct { + MaxOptionsAmount int `json:"max_options_amount"` + MinOptionsAmount int `json:"min_options_amount"` + AllowNota bool `json:"allow_nota"` + } + if err := json.Unmarshal(config, &cfg); err != nil { + return fmt.Errorf("parsing selection config: %w", err) + } + + var configID int + sql := `INSERT INTO poll_config_selection + (poll_id, max_options_amount, min_options_amount, allow_nota) + VALUES ($1, $2, $3, $4) + RETURNING id;` + if err := tx.QueryRow(ctx, sql, pollID, cfg.MaxOptionsAmount, cfg.MinOptionsAmount, cfg.AllowNota).Scan(&configID); err != nil { + return fmt.Errorf("save approval config: %w", err) + } + + configObjectID = fmt.Sprintf("poll_config_selection/%d", configID) + optionsRequired = true + + case "rating_score": + var cfg struct { + MaxOptionsAmount int `json:"max_options_amount"` + MinOptionsAmount int `json:"min_options_amount"` + MaxVotesPerOption int `json:"max_votes_per_option"` + MaxVoteSum int `json:"max_vote_sum"` + MinVoteSum int `json:"min_vote_sum"` + } + if err := json.Unmarshal(config, &cfg); err != nil { + return fmt.Errorf("parsing rating score config: %w", err) + } + + var configID int + sql := `INSERT INTO poll_config_rating_score + (poll_id, max_options_amount, min_options_amount, max_votes_per_option, max_vote_sum, min_vote_sum) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id;` + if err := tx.QueryRow( + ctx, + sql, + pollID, + cfg.MaxOptionsAmount, + cfg.MinOptionsAmount, + cfg.MaxVotesPerOption, + cfg.MaxVoteSum, + cfg.MinVoteSum, + ).Scan(&configID); err != nil { + return fmt.Errorf("save approval config: %w", err) + } + + configObjectID = fmt.Sprintf("poll_config_rating_score/%d", configID) + optionsRequired = true + + case "rating_approval": + var cfg struct { + MaxOptionsAmount int `json:"max_options_amount"` + MinOptionsAmount int `json:"min_options_amount"` + AllowAbstain dsfetch.Maybe[bool] `json:"allow_abstain"` + } + if err := json.Unmarshal(config, &cfg); err != nil { + return fmt.Errorf("parsing rating approval config: %w", err) + } + + allowAbstain, set := cfg.AllowAbstain.Value() + if !set { + allowAbstain = true + } + + var configID int + sql := `INSERT INTO poll_config_rating_approval + (poll_id, max_options_amount, min_options_amount, allow_abstain) + VALUES ($1, $2, $3, $4) + RETURNING id;` + if err := tx.QueryRow( + ctx, + sql, + pollID, + cfg.MaxOptionsAmount, + cfg.MinOptionsAmount, + allowAbstain, + ).Scan(&configID); err != nil { + return fmt.Errorf("save approval config: %w", err) + } -// Clear removes all knowlage of a poll. -func (v *Vote) Clear(ctx context.Context, pollID int) error { - if err := v.fastBackend.Clear(ctx, pollID); err != nil { - return fmt.Errorf("clearing fastBackend: %w", err) + configObjectID = fmt.Sprintf("poll_config_rating_approval/%d", configID) + optionsRequired = true } - if err := v.longBackend.Clear(ctx, pollID); err != nil { - return fmt.Errorf("clearing longBackend: %w", err) + if err := insertOption(ctx, tx, config, configObjectID, optionsRequired); err != nil { + return fmt.Errorf("insert options: %w", err) } - v.liveVotesMu.Lock() - v.liveVotes[pollID] = nil - v.liveVotesMu.Unlock() + sql := `UPDATE poll SET config_id = $2 WHERE id = $1` + if _, err := tx.Exec(ctx, sql, pollID, configObjectID); err != nil { + return fmt.Errorf("update config value of poll: %w", err) + } return nil } -// ClearAll removes all knowlage of all polls and the datastore-cache. -func (v *Vote) ClearAll(ctx context.Context) error { - // Reset the cache if it has the ResetCach() method. - type ResetCacher interface { - Reset() +func insertOption(ctx context.Context, tx pgx.Tx, config json.RawMessage, configObjectID string, required bool) error { + var cfg struct { + Type string `json:"option_type"` + Options []any `json:"options"` } - if r, ok := v.flow.(ResetCacher); ok { - r.Reset() + if err := json.Unmarshal(config, &cfg); err != nil { + return fmt.Errorf("unmarshal config: %w", err) } - if err := v.fastBackend.ClearAll(ctx); err != nil { - return fmt.Errorf("clearing fastBackend: %w", err) + if len(cfg.Options) == 0 { + if !required { + return nil + } + return MessageError(ErrInvalid, "Need at least value in options") } - if err := v.longBackend.ClearAll(ctx); err != nil { - return fmt.Errorf("clearing long Backend: %w", err) + for _, option := range cfg.Options { + str, ok := option.(string) + if !ok { + continue + } + if slices.Contains(reservedOptionNames, str) { + return MessageErrorf(ErrInternal, "%s is not allowed as an option", option) + } } - v.liveVotesMu.Lock() - v.liveVotes = make(map[int]map[int][]byte) - v.liveVotesMu.Unlock() + var sqlColumns string + var args []any - return nil -} + switch cfg.Type { + case "text": + sqlColumns = `(poll_config_id, weight, text)` + case "meeting_user": + sqlColumns = `(poll_config_id, weight, meeting_user_id)` + default: + return MessageErrorf(ErrInvalid, "unknown option_type %q", cfg.Type) + } -// Vote validates and saves the vote. -func (v *Vote) Vote(ctx context.Context, pollID, requestUser int, r io.Reader) error { - ds := dsmodels.New(v.flow) - poll, err := ds.Poll(pollID).First(ctx) - if err != nil { - var doesNotExist dsfetch.DoesNotExistError - if errors.As(err, &doesNotExist) { - return MessageErrorf(ErrNotExists, "Poll %d does not exist", pollID) - } - return fmt.Errorf("loading poll: %w", err) + for weight, opt := range cfg.Options { + args = append(args, configObjectID, weight, opt) } - log.Debug("Poll config: %v", poll) - if err := ensurePresent(ctx, &ds.Fetch, poll.MeetingID, requestUser); err != nil { - return err + valuePlaceholders := make([]string, len(cfg.Options)) + for i := range cfg.Options { + valuePlaceholders[i] = fmt.Sprintf("($%d, $%d, $%d)", 3*i+1, 3*i+2, 3*i+3) } - var vote ballot - if err := json.NewDecoder(r).Decode(&vote); err != nil { - return MessageErrorf(ErrInvalid, "decoding payload: %v", err) + query := fmt.Sprintf( + "INSERT INTO poll_config_option %s VALUES %s", + sqlColumns, + strings.Join(valuePlaceholders, ", "), + ) + + if _, err := tx.Exec(ctx, query, args...); err != nil { + return fmt.Errorf("insert options: %w", err) } - voteUser, exist := vote.UserID.Value() - if !exist { - voteUser = requestUser + return nil +} + +type createInput struct { + Title string `json:"title"` + ContentObjectID string `json:"content_object_id"` + MeetingID int `json:"meeting_id"` + Method string `json:"method"` + Config json.RawMessage `json:"config"` + Visibility string `json:"visibility"` + EntitledGroupIDs []int `json:"entitled_group_ids"` + Published bool `json:"published"` + Result json.RawMessage `json:"result"` + AllowVoteSplit bool `json:"allow_vote_split"` +} + +func parseCreateInput(r io.Reader, electronicVotingEnabled bool) (createInput, error) { + var ci createInput + if err := json.NewDecoder(r).Decode(&ci); err != nil { + return createInput{}, fmt.Errorf("reading json: %w", err) } - if voteUser == 0 { - return MessageError(ErrNotAllowed, "Votes for anonymous user are not allowed") + if ci.Title == "" { + return createInput{}, MessageError(ErrInvalid, "Title can not be empty") } - voteMeetingUserID, found, err := getMeetingUser(ctx, &ds.Fetch, voteUser, poll.MeetingID) - if err != nil { - return fmt.Errorf("get meeting user for vote user: %w", err) + if ci.ContentObjectID == "" { + return createInput{}, MessageError(ErrInvalid, "Content Object ID can not be empty") } - if !found { - return MessageError(ErrNotAllowed, "You are not in the right meeting") + if ci.MeetingID == 0 { + return createInput{}, MessageError(ErrInvalid, "Meeting ID can not be empty") } - if err := ensureVoteUser(ctx, &ds.Fetch, poll, voteUser, voteMeetingUserID, requestUser); err != nil { - return err + if ci.Method == "" { + return createInput{}, MessageError(ErrInvalid, "Method can not be empty") } - if validation := validate(poll, vote.Value); validation != "" { - return MessageError(ErrInvalid, validation) + if ci.Config == nil { + return createInput{}, MessageError(ErrInvalid, "Config can not be empty") } - // voteData.Weight is a DecimalField with 6 zeros. - var voteWeightEnabled bool - var meetingUserVoteWeight decimal.Decimal - var userDefaultVoteWeight decimal.Decimal - ds.Meeting_UsersEnableVoteWeight(poll.MeetingID).Lazy(&voteWeightEnabled) - ds.MeetingUser_VoteWeight(voteMeetingUserID).Lazy(&meetingUserVoteWeight) - ds.User_DefaultVoteWeight(voteUser).Lazy(&userDefaultVoteWeight) + if ci.Visibility == "" { + return createInput{}, MessageError(ErrInvalid, "Visibility can not be empty") + } - if err := ds.Execute(ctx); err != nil { - return fmt.Errorf("getting vote weight: %w", err) + if ci.Visibility == "secret" && ci.AllowVoteSplit { + return createInput{}, MessageError(ErrInvalid, "Vote splitting is not allowed for secret polls") } - var voteWeight decimal.Decimal - if voteWeightEnabled { - voteWeight = meetingUserVoteWeight - if voteWeight.IsZero() { - voteWeight = userDefaultVoteWeight + switch ci.Visibility { + case "manually": + if len(ci.EntitledGroupIDs) > 0 { + return createInput{}, MessageError(ErrInvalid, "Entitled Group IDs can not be set when visibility is set to manually") + } + + default: + if !electronicVotingEnabled { + return createInput{}, MessageError(ErrNotAllowed, "Electronic voting is not enabled. Only polls with visibility set to manually are allowed.") + } + + if ci.Result != nil { + return createInput{}, MessageError(ErrInvalid, "Result can only be set when visibility is set to manually") } } - if voteWeight.IsZero() { - voteWeight = decimal.NewFromInt(1) + return ci, nil +} + +// Update changes a poll. +func (v *Vote) Update(ctx context.Context, pollID int, requestUserID int, r io.Reader) error { + poll, err := fetchPoll(ctx, v.flow, pollID) + if err != nil { + return fmt.Errorf("fetching poll: %w", err) } - log.Debug("Using voteWeight %s", voteWeight.String()) + if err := canManagePoll(ctx, v.flow, poll.MeetingID, poll.ContentObjectID, requestUserID); err != nil { + return fmt.Errorf("check permissions: %w", err) + } - voteData := struct { - RequestUser int `json:"request_user_id,omitempty"` - VoteUser int `json:"vote_user_id,omitempty"` - Value json.RawMessage `json:"value"` - Weight string `json:"weight"` - }{ - requestUser, - voteUser, - vote.Value.original, - voteWeight.StringFixed(6), + electronicVotingEnabled, err := dsfetch.New(v.flow).Organization_EnableElectronicVoting(1).Value(ctx) + if err != nil { + return fmt.Errorf("fetch organization/1/enable_electronic_voting: %w", err) } - if poll.Type != "named" { - voteData.RequestUser = 0 - voteData.VoteUser = 0 + ui, err := parseUpdateInput(r, poll, electronicVotingEnabled) + if err != nil { + return fmt.Errorf("parse update body: %w", err) } - bs, err := json.Marshal(voteData) + tx, err := v.querier.Begin(ctx) if err != nil { - return fmt.Errorf("decoding vote data: %w", err) + return fmt.Errorf("begin transaction: %w", err) } + defer tx.Rollback(ctx) - if err := v.backend(poll).Vote(ctx, pollID, voteUser, bs); err != nil { - var errNotExist interface{ DoesNotExist() } - if errors.As(err, &errNotExist) { - return ErrNotExists + sql, values := ui.query(pollID) + if len(values) > 0 { + if _, err := tx.Exec(ctx, sql, values...); err != nil { + return fmt.Errorf("update poll: %w", err) } + } - var errDoubleVote interface{ DoubleVote() } - if errors.As(err, &errDoubleVote) { - return ErrDoubleVote + if ui.Method != "" || ui.Config != nil { + method := pollMethod(poll) + if ui.Method != "" { + method = ui.Method } - var errNotOpen interface{ Stopped() } - if errors.As(err, &errNotOpen) { - return ErrStopped + if err := saveConfig(ctx, tx, pollID, method, ui.Config); err != nil { + return fmt.Errorf("save poll config: %w", err) } - - return fmt.Errorf("save vote: %w", err) } - var liveVote []byte - if poll.Type == "named" { - liveVote = bs + if len(ui.EntitledGroupIDs) > 0 { + sql := "DELETE FROM nm_group_poll_ids_poll_t WHERE poll_id = $1" + if _, err := tx.Exec(ctx, sql, pollID); err != nil { + return fmt.Errorf("deleting existing group associations: %w", err) + } + + placeholders := make([]string, len(ui.EntitledGroupIDs)) + args := make([]any, len(ui.EntitledGroupIDs)*2) + + for i, groupID := range ui.EntitledGroupIDs { + placeholders[i] = fmt.Sprintf("($%d, $%d)", i*2+1, i*2+2) + args[i*2] = groupID + args[i*2+1] = poll.ID + } + + groupSQL := fmt.Sprintf( + "INSERT INTO nm_group_poll_ids_poll_t (group_id, poll_id) VALUES %s", + strings.Join(placeholders, ", "), + ) + + if _, err := tx.Exec(ctx, groupSQL, args...); err != nil { + return fmt.Errorf("insert group-poll relations: %w", err) + } } - v.liveVotesMu.Lock() - if v.liveVotes[pollID] == nil { - v.liveVotes[pollID] = make(map[int][]byte) + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit transaction: %w", err) } - v.liveVotes[pollID][voteUser] = liveVote - v.liveVotesMu.Unlock() return nil } -// getMeetingUser returns the meeting_user id between a userID and a meetingID. -func getMeetingUser(ctx context.Context, fetch *dsfetch.Fetch, userID, meetingID int) (int, bool, error) { - meetingUserIDs, err := fetch.User_MeetingUserIDs(userID).Value(ctx) - if err != nil { - return 0, false, fmt.Errorf("getting all meeting_user ids: %w", err) +type updateInput struct { + Title string `json:"title"` + Method string `json:"method"` + Config json.RawMessage `json:"config"` + Visibility string `json:"visibility"` + EntitledGroupIDs []int `json:"entitled_group_ids"` + Published dsfetch.Maybe[bool] `json:"published"` + Result json.RawMessage `json:"result"` + AllowVoteSplit dsfetch.Maybe[bool] `json:"allow_vote_split"` +} + +func parseUpdateInput(r io.Reader, poll dsmodels.Poll, electronicVotingEnabled bool) (updateInput, error) { + var ui updateInput + if err := json.NewDecoder(r).Decode(&ui); err != nil { + return updateInput{}, fmt.Errorf("decoding update input: %w", err) } - meetingIDs := make([]int, len(meetingUserIDs)) - for i := 0; i < len(meetingUserIDs); i++ { - fetch.MeetingUser_MeetingID(meetingUserIDs[i]).Lazy(&meetingIDs[i]) + if poll.Visibility == "manually" { + if len(ui.EntitledGroupIDs) > 0 { + return updateInput{}, MessageError(ErrNotAllowed, "Entitled Group IDs can not be set when visibility is set to manually") + } + return ui, nil } - if err := fetch.Execute(ctx); err != nil { - return 0, false, fmt.Errorf("get all meeting IDs: %w", err) + if ui.Visibility == "manually" { + return updateInput{}, MessageError(ErrNotAllowed, "A poll can not be changed manually") } - for i, mid := range meetingIDs { - if mid == meetingID { - return meetingUserIDs[i], true, nil + if poll.State != "created" { + if ui.Method != "" { + return updateInput{}, MessageError(ErrNotAllowed, "method can only be changed before the poll has started") } - } - return 0, false, nil -} + if ui.Config != nil { + return updateInput{}, MessageError(ErrNotAllowed, "config can only be changed before the poll has started") + } -// ensurePresent makes sure that the user sending the vote request is present. -func ensurePresent(ctx context.Context, ds *dsfetch.Fetch, meetingID, user int) error { - presentMeetings, err := ds.User_IsPresentInMeetingIDs(user).Value(ctx) - if err != nil { - return fmt.Errorf("fetching is present in meetings: %w", err) - } + if ui.Visibility != "" { + return updateInput{}, MessageError(ErrNotAllowed, "visibility can only be changed before the poll has started") + } - for _, present := range presentMeetings { - if present == meetingID { - return nil + if ui.EntitledGroupIDs != nil { + return updateInput{}, MessageError(ErrNotAllowed, "entitled group ids can only be changed before the poll has started") } + + if !ui.AllowVoteSplit.Null() { + return updateInput{}, MessageError(ErrNotAllowed, "allow vote split can only be changed before the poll has started") + } + } + + if !electronicVotingEnabled { + return updateInput{}, MessageError(ErrNotAllowed, "Electronic voting is not enabled. Only polls with visibility set to manually are allowed.") } - return MessageErrorf(ErrNotAllowed, "You have to be present in meeting %d", meetingID) + + if ui.Result != nil { + return updateInput{}, MessageError(ErrNotAllowed, "Result can only be set when visibility is set to manually") + } + + return ui, nil } -// ensureVoteUser makes sure the user from the vote: -// * the delegation is correct and -// * is in the correct group -func ensureVoteUser(ctx context.Context, ds *dsfetch.Fetch, poll dsmodels.Poll, voteUser, voteMeetingUserID, requestUser int) error { - groupIDs, err := ds.MeetingUser_GroupIDs(voteMeetingUserID).Value(ctx) - if err != nil { - return fmt.Errorf("fetching groups of user %d in meeting %d: %w", voteUser, poll.MeetingID, err) +func (ui updateInput) query(pollID int) (string, []any) { + var setParts []string + var args []any + argIndex := 1 + + if ui.Title != "" { + setParts = append(setParts, fmt.Sprintf("title = $%d", argIndex)) + args = append(args, ui.Title) + argIndex++ } - if !equalElement(groupIDs, poll.EntitledGroupIDs) { - return MessageErrorf(ErrNotAllowed, "User %d is not allowed to vote. He is not in an entitled group", voteUser) + if ui.Method != "" { + setParts = append(setParts, fmt.Sprintf("method = $%d", argIndex)) + args = append(args, ui.Method) + argIndex++ } - delegationActivated, err := ds.Meeting_UsersEnableVoteDelegations(poll.MeetingID).Value(ctx) - if err != nil { - return fmt.Errorf("fetching user enable vote delegation: %w", err) + if ui.Config != nil { + setParts = append(setParts, fmt.Sprintf("config = $%d", argIndex)) + args = append(args, string(ui.Config)) + argIndex++ } - forbitDelegateToVote, err := ds.Meeting_UsersForbidDelegatorToVote(poll.MeetingID).Value(ctx) - if err != nil { - return fmt.Errorf("getting users_forbid_delegator_to_vote: %w", err) + if ui.Visibility != "" { + setParts = append(setParts, fmt.Sprintf("visibility = $%d", argIndex)) + args = append(args, ui.Visibility) + argIndex++ } - delegation, err := ds.MeetingUser_VoteDelegatedToID(voteMeetingUserID).Value(ctx) - if err != nil { - return fmt.Errorf("fetching delegation : %w", err) + if published, hasValue := ui.Published.Value(); hasValue { + setParts = append(setParts, fmt.Sprintf("published = $%d", argIndex)) + args = append(args, published) + argIndex++ } - if delegationActivated && forbitDelegateToVote && !delegation.Null() && voteUser == requestUser { - return MessageError(ErrNotAllowed, "You have delegated your vote and therefore can not vote for your self") + if ui.Result != nil { + setParts = append(setParts, fmt.Sprintf("result = $%d", argIndex)) + args = append(args, string(ui.Result)) + argIndex++ } - if voteUser == requestUser { - return nil + if allowVoteSplit, hasValue := ui.AllowVoteSplit.Value(); hasValue { + setParts = append(setParts, fmt.Sprintf("allow_vote_split = $%d", argIndex)) + args = append(args, allowVoteSplit) + argIndex++ + } + + if len(setParts) == 0 { + return "", nil } - log.Debug("Vote delegation") + query := fmt.Sprintf("UPDATE poll SET %s WHERE id = $%d", + strings.Join(setParts, ", "), + argIndex) - if !delegationActivated { - return MessageErrorf(ErrNotAllowed, "Vote delegation is not activated in meeting %d", poll.MeetingID) + args = append(args, pollID) + + return query, args +} + +// Delete removes a poll. +func (v *Vote) Delete(ctx context.Context, pollID int, requestUserID int) error { + poll, err := fetchPoll(ctx, v.flow, pollID) + if err != nil { + return fmt.Errorf("fetching poll: %w", err) + } + + if err := canManagePoll(ctx, v.flow, poll.MeetingID, poll.ContentObjectID, requestUserID); err != nil { + return fmt.Errorf("check permissions: %w", err) } - requestMeetingUserID, found, err := getMeetingUser(ctx, ds, requestUser, poll.MeetingID) + tx, err := v.querier.Begin(ctx) if err != nil { - return fmt.Errorf("getting meeting_user for request user: %w", err) + return fmt.Errorf("begin transaction: %w", err) } + defer tx.Rollback(ctx) - if !found { - return MessageError(ErrNotAllowed, "You are not in the right meeting") + deleteStatements := []string{ + `DELETE FROM poll_config_approval WHERE poll_id = $1`, + `DELETE FROM poll_config_selection WHERE poll_id = $1`, + `DELETE FROM poll_config_rating_score WHERE poll_id = $1`, + `DELETE FROM poll_config_rating_approval WHERE poll_id = $1`, + } + for _, sql := range deleteStatements { + if _, err := tx.Exec(ctx, sql, pollID); err != nil { + return fmt.Errorf("remove old config entries for poll %d: %w", pollID, err) + } } - if id, ok := delegation.Value(); !ok || id != requestMeetingUserID { - return MessageErrorf(ErrNotAllowed, "You can not vote for user %d", voteUser) + sql := `DELETE FROM poll WHERE id = $1;` + if _, err := tx.Exec(ctx, sql, pollID); err != nil { + return fmt.Errorf("delete poll: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit transaction: %w", err) } return nil } -// delegatedUserIDs returns all user ids for which the user can vote. -func delegatedUserIDs(ctx context.Context, fetch *dsfetch.Fetch, userID int) ([]int, error) { - meetingUserIDs, err := fetch.User_MeetingUserIDs(userID).Value(ctx) +// Start validates a poll and set its state to started. +func (v *Vote) Start(ctx context.Context, pollID int, requestUserID int) error { + poll, err := fetchPoll(ctx, v.flow, pollID) if err != nil { - return nil, fmt.Errorf("fetching meeting user: %w", err) + return fmt.Errorf("fetching poll: %w", err) } - meetingUserDelegationsIDs := make([][]int, len(meetingUserIDs)) - for i, muid := range meetingUserIDs { - fetch.MeetingUser_VoteDelegationsFromIDs(muid).Lazy(&meetingUserDelegationsIDs[i]) + if err := canManagePoll(ctx, v.flow, poll.MeetingID, poll.ContentObjectID, requestUserID); err != nil { + return fmt.Errorf("check permissions: %w", err) } - if err := fetch.Execute(ctx); err != nil { - return nil, fmt.Errorf("getting vote_delegation_from values: %w", err) + if poll.State == "finished" { + return MessageErrorf(ErrInvalid, "Poll %d is already finished", pollID) } - var delegatedMeetingUserIDs []int - for i := range meetingUserDelegationsIDs { - delegatedMeetingUserIDs = append(delegatedMeetingUserIDs, meetingUserDelegationsIDs[i]...) + if err := Preload(ctx, dsfetch.New(v.flow), poll.ID, poll.MeetingID); err != nil { + return fmt.Errorf("preloading poll: %w", err) } - userIDs := make([]int, len(delegatedMeetingUserIDs)) - for i := range delegatedMeetingUserIDs { - fetch.MeetingUser_UserID(delegatedMeetingUserIDs[i]).Lazy(&userIDs[i]) + sql := `UPDATE poll SET state = 'started' WHERE id = $1 AND state != 'finished';` + commandTag, err := v.querier.Exec(ctx, sql, pollID) + if err != nil { + return fmt.Errorf("set poll %d to started: %w", pollID, err) } - if err := fetch.Execute(ctx); err != nil { - return nil, fmt.Errorf("getting user_ids from meeting_user_ids: %w", err) + if commandTag.RowsAffected() != 1 { + return fmt.Errorf("poll %d not found or not in 'created' state", pollID) } - return userIDs, nil + return nil } -// Voted tells, on which the requestUser has already voted. -func (v *Vote) Voted(ctx context.Context, pollIDs []int, requestUser int) (map[int][]int, error) { - ds := dsfetch.New(v.flow) - userIDs, err := delegatedUserIDs(ctx, ds, requestUser) +// Finalize ends a poll. +// +// - If in the started state, it creates poll/result. +// - Sets the state to `finished`. +// - Sets the `published` flag. +// - With the flag `anonymize`, clears all user_ids from the coresponding votes. +func (v *Vote) Finalize(ctx context.Context, pollID int, requestUserID int, publish bool, anonymize bool) error { + poll, err := fetchPoll(ctx, v.flow, pollID) if err != nil { - return nil, fmt.Errorf("getting all delegated users: %w", err) + return fmt.Errorf("fetching poll: %w", err) } - requestedUserIDs := make(map[int]struct{}, len(userIDs)+1) - requestedUserIDs[requestUser] = struct{}{} - for _, uid := range userIDs { - requestedUserIDs[uid] = struct{}{} + if err := canManagePoll(ctx, v.flow, poll.MeetingID, poll.ContentObjectID, requestUserID); err != nil { + return fmt.Errorf("check permissions: %w", err) } - requestedPollIDs := make(map[int]struct{}, len(pollIDs)) - for _, pid := range pollIDs { - requestedPollIDs[pid] = struct{}{} + if poll.State == "created" { + return MessageErrorf(ErrInvalid, "Poll %d has not started yet.", pollID) } - v.liveVotesMu.Lock() - defer v.liveVotesMu.Unlock() + tx, err := v.querier.Begin(ctx) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + defer tx.Rollback(ctx) - out := make(map[int][]int, len(pollIDs)) - for pid, userID2Vote := range v.liveVotes { - if _, ok := requestedPollIDs[pid]; !ok { - continue + if poll.State == `started` { + ds := dsmodels.New(v.flow) + + ballots, err := ds.Ballot(poll.BallotIDs...).Get(ctx) + if err != nil { + return fmt.Errorf("fetch votes of poll %d: %w", poll.ID, err) + } + + config, err := v.EncodeConfig(ctx, poll) + if err != nil { + return fmt.Errorf("encode config: %w", err) } - for uid := range maps.Keys(userID2Vote) { - if _, ok := requestedUserIDs[uid]; ok { - out[pid] = append(out[pid], uid) + result, err := CreateResult(pollMethod(poll), config, poll.AllowVoteSplit, ballots) + if err != nil { + return fmt.Errorf("create poll result: %w", err) + } + + votedMeetingUserIDs := make([]int, len(ballots)) + for i, vote := range ballots { + meetingUserID, set := vote.RepresentedMeetingUserID.Value() + if !set { + return fmt.Errorf("vote %d has no representedMeetingUserID", vote.ID) } + votedMeetingUserIDs[i] = meetingUserID } - } - for _, pid := range pollIDs { - if _, ok := out[pid]; !ok { - out[pid] = nil + sql := `UPDATE poll SET result = $1 WHERE id = $2;` + if _, err := tx.Exec(ctx, sql, result, pollID); err != nil { + return fmt.Errorf("set result of poll %d: %w", pollID, err) } - } - return out, nil -} + if len(votedMeetingUserIDs) > 0 { + placeholders := make([]string, len(votedMeetingUserIDs)) + args := make([]any, len(votedMeetingUserIDs)*2) -// AllLiveVotes returns for all running polls the vote from each user. -func (v *Vote) AllLiveVotes(ctx context.Context) map[int]map[int]*string { - v.liveVotesMu.Lock() - defer v.liveVotesMu.Unlock() + for i, votedUserID := range votedMeetingUserIDs { + placeholders[i] = fmt.Sprintf("($%d, $%d)", i*2+1, i*2+2) + args[i*2] = votedUserID + args[i*2+1] = pollID + } - ds := dsmodels.New(v.flow) + votedSQL := fmt.Sprintf( + "INSERT INTO nm_meeting_user_poll_voted_ids_poll_t (meeting_user_id, poll_id) VALUES %s", + strings.Join(placeholders, ", "), + ) - out := make(map[int]map[int]*string, len(v.liveVotes)) - for pollID, userID2Vote := range v.liveVotes { - poll, err := ds.Poll(pollID).First(ctx) - if err != nil { - continue + if _, err := tx.Exec(ctx, votedSQL, args...); err != nil { + return fmt.Errorf("insert voted_user_ids to meeting_user relations: %w", err) + } } + } - out[pollID] = make(map[int]*string, len(userID2Vote)) + sql := `UPDATE poll SET state = 'finished', published = $1 WHERE id = $2;` + if _, err := tx.Exec(ctx, sql, publish, pollID); err != nil { + return fmt.Errorf("set poll %d to finished and publish to %v: %w", pollID, publish, err) + } - if poll.LiveVotingEnabled && poll.Type == "named" { - // Only send votes an votes, where live voting is enabled and its a - // named vote. Remove the votes for all other votes. - for userID, vote := range userID2Vote { - if vote == nil { - out[pollID] = nil - continue - } - str := string(vote) - out[pollID][userID] = &str - } - continue + if anonymize { + if poll.Visibility == "named" { + return MessageError(ErrNotAllowed, "A named-poll can not be anonymized.") } - for userID := range userID2Vote { - out[pollID][userID] = nil + sql := `UPDATE ballot + SET acting_meeting_user_id = NULL, represented_meeting_user_id = NULL + WHERE poll_id = $1` + + if _, err := tx.Exec(ctx, sql, pollID); err != nil { + return fmt.Errorf("anonymize ballots: %w", err) } } - return out + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit transaction: %w", err) + } + + return nil } -// loadVoted creates the value for v.voted by the backends. -func (v *Vote) loadVoted(ctx context.Context) error { - combinedData, err := v.fastBackend.LiveVotes(ctx) +// Reset removes all votes from a poll and sets its state to created. +func (v *Vote) Reset(ctx context.Context, pollID int, requestUserID int) error { + poll, err := fetchPoll(ctx, v.flow, pollID) if err != nil { - return fmt.Errorf("fetching data from fast backend: %w", err) + return fmt.Errorf("fetching poll: %w", err) } - longData, err := v.longBackend.LiveVotes(ctx) - if err != nil { - return fmt.Errorf("fetching data from long backend: %w", err) + if err := canManagePoll(ctx, v.flow, poll.MeetingID, poll.ContentObjectID, requestUserID); err != nil { + return fmt.Errorf("check permissions: %w", err) } - maps.Copy(combinedData, longData) + tx, err := v.querier.Begin(ctx) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + defer tx.Rollback(ctx) - v.liveVotesMu.Lock() - v.liveVotes = combinedData - v.liveVotesMu.Unlock() - return nil -} + var exists bool + err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM poll WHERE id = $1)`, pollID).Scan(&exists) + if err != nil { + return fmt.Errorf("check poll existence: %w", err) + } -// Backend is a storage for the poll options. -type Backend interface { - // Start opens the poll for votes. To start a poll that is already started - // is ok. To start an stopped poll is also ok, but it has to be a noop (the - // stop-state does not change). - Start(ctx context.Context, pollID int) error + if !exists { + return MessageErrorf(ErrInvalid, "Poll with id %d not found", pollID) + } - // Vote saves vote data into the backend. The backend has to check that the - // poll is started and the userID has not voted before. - // - // If the user has already voted, an Error with method `DoubleVote()` has to - // be returned. If the poll has not started, an error with the method - // `DoesNotExist()` is required. An a stopped vote, it has to be `Stopped()`. - // - // The return value is the number of already voted objects. - Vote(ctx context.Context, pollID int, userID int, object []byte) error + deleteVoteQuery := `DELETE FROM ballot WHERE poll_id = $1` + if _, err := tx.Exec(ctx, deleteVoteQuery, pollID); err != nil { + return fmt.Errorf("delete ballots: %w", err) + } - // Stop ends a poll and returns all poll objects and all userIDs from users - // that have voted. It is ok to call Stop() on a stopped poll. On a unknown - // poll `DoesNotExist()` has to be returned. - Stop(ctx context.Context, pollID int) ([][]byte, []int, error) + state := "created" + if poll.Visibility == "manually" { + state = "finished" + } - // Clear has to remove all data. It can be called on a started or stopped or - // non existing poll. - Clear(ctx context.Context, pollID int) error + updateQuery := `UPDATE poll SET state = $1, published = false, result = '' WHERE id = $2` + if _, err := tx.Exec(ctx, updateQuery, state, pollID); err != nil { + return fmt.Errorf("reset poll state: %w", err) + } - // ClearAll removes all data from the backend. - ClearAll(ctx context.Context) error + deleteVotedQuery := `DELETE FROM nm_meeting_user_poll_voted_ids_poll_t WHERE poll_id = $1` + if _, err := tx.Exec(ctx, deleteVotedQuery, pollID); err != nil { + return fmt.Errorf("delete poll votes: %w", err) + } - // LiveVotes returns all votes from each user. - LiveVotes(ctx context.Context) (map[int]map[int][]byte, error) + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit transaction: %w", err) + } - fmt.Stringer + return nil } -// preload loads all data in the cache, that is needed later for the vote -// requests. -func preload(ctx context.Context, ds *dsfetch.Fetch, poll dsmodels.Poll) error { - var dummyBool bool - var dummyIntSlice []int - var dummyDecimal decimal.Decimal - var dummyManybeInt dsfetch.Maybe[int] - var dummyInt int - ds.Meeting_UsersEnableVoteWeight(poll.MeetingID).Lazy(&dummyBool) - ds.Meeting_UsersEnableVoteDelegations(poll.MeetingID).Lazy(&dummyBool) - ds.Meeting_UsersForbidDelegatorToVote(poll.MeetingID).Lazy(&dummyBool) +// Vote validates and saves the vote. +func (v *Vote) Vote(ctx context.Context, pollID, requestUserID int, r io.Reader) error { + if requestUserID == 0 { + return MessageErrorf(ErrInvalid, "Anonymous can not vote") + } - meetingUserIDsList := make([][]int, len(poll.EntitledGroupIDs)) - for i, groupID := range poll.EntitledGroupIDs { - ds.Group_MeetingUserIDs(groupID).Lazy(&meetingUserIDsList[i]) + poll, err := fetchPoll(ctx, v.flow, pollID) + if err != nil { + return fmt.Errorf("fetching poll: %w", err) } - // First database request to get meeting/enable_vote_weight and all - // meeting_users from all entitled groups. - if err := ds.Execute(ctx); err != nil { - return fmt.Errorf("fetching users: %w", err) + var body struct { + MeetingUserID dsfetch.Maybe[int] `json:"meeting_user_id"` + Value json.RawMessage `json:"value"` + Split bool `json:"split"` } - var userIDs []*int - for _, meetingUserIDs := range meetingUserIDsList { - for _, muID := range meetingUserIDs { - var uid int - userIDs = append(userIDs, &uid) - ds.MeetingUser_UserID(muID).Lazy(&uid) - ds.MeetingUser_GroupIDs(muID).Lazy(&dummyIntSlice) - ds.MeetingUser_VoteWeight(muID).Lazy(&dummyDecimal) - ds.MeetingUser_VoteDelegatedToID(muID).Lazy(&dummyManybeInt) - ds.MeetingUser_MeetingID(muID).Lazy(&dummyInt) - } + if err := json.NewDecoder(r).Decode(&body); err != nil { + return MessageError(ErrInvalid, "Invalid body") + } + + if body.Split && !poll.AllowVoteSplit { + return MessageErrorf(ErrInvalid, "Vote split is not allowed for poll %d", poll.ID) + } + + fetch := dsfetch.New(v.flow) + actingMeetingUserID, found, err := getMeetingUser(ctx, fetch, requestUserID, poll.MeetingID) + if err != nil { + return fmt.Errorf("getting meeting user of request user: %w", err) + } + if !found { + return MessageErrorf(ErrInvalid, "You have to be in the meeting to vote") + } + + representedMeetingUserID := actingMeetingUserID + if meetingUserID, set := body.MeetingUserID.Value(); set { + representedMeetingUserID = meetingUserID + } + + if err := allowedToVote(ctx, fetch, poll, representedMeetingUserID, actingMeetingUserID); err != nil { + return fmt.Errorf("allowedToVote: %w", err) } - // Second database request to get all user ids and meeting_user_data. - if err := ds.Execute(ctx); err != nil { - return fmt.Errorf("preload meeting user data: %w", err) + ballotValue := string(body.Value) + weight, err := CalcVoteWeight(ctx, fetch, representedMeetingUserID) + if err != nil { + return fmt.Errorf("calc vote weight: %w", err) } - var delegatedMeetingUserIDs []int - for _, muIDs := range meetingUserIDsList { - for _, muID := range muIDs { - // This does not send a db request, since the value was fetched in - // the block above. - mID, err := ds.MeetingUser_VoteDelegatedToID(muID).Value(ctx) + if !poll.AllowInvalid { + splitted := map[decimal.Decimal]json.RawMessage{decimal.Zero: body.Value} + + if body.Split { + splitted, err = split(weight, body.Value) if err != nil { - return fmt.Errorf("getting vote delegated to for meeting user %d: %w", muID, err) + return fmt.Errorf("split vote: %w", err) } - if id, ok := mID.Value(); ok { - delegatedMeetingUserIDs = append(delegatedMeetingUserIDs, id) + } + + method := pollMethod(poll) + config, err := v.EncodeConfig(ctx, poll) + if err != nil { + return fmt.Errorf("encode config: %w", err) + } + + for _, value := range splitted { + if err := ValidateBallot(method, config, value); err != nil { + return fmt.Errorf("validate ballot: %w", err) } } } - delegatedUserIDs := make([]int, len(delegatedMeetingUserIDs)) - for i, muID := range delegatedMeetingUserIDs { - ds.MeetingUser_UserID(muID).Lazy(&delegatedUserIDs[i]) - ds.MeetingUser_MeetingID(muID).Lazy(&dummyInt) + sql := `WITH + poll_check AS ( + SELECT + id, + state, + CASE + WHEN id IS NULL THEN 'POLL_NOT_FOUND' + WHEN state != 'started' THEN 'POLL_NOT_STARTED' + ELSE 'POLL_VALID' + END as poll_status + FROM poll + WHERE id = $1 + ), + ballot_check AS ( + SELECT + COUNT(*) as existing_ballots, + CASE + WHEN COUNT(*) > 0 THEN 'USER_HAS_VOTED_BEFORE' + ELSE 'BALLOT_OK' + END as ballot_status + FROM ballot + WHERE poll_id = $1 AND represented_meeting_user_id = $5 + ), + inserted AS ( + INSERT INTO ballot + (poll_id, value, weight, acting_meeting_user_id, represented_meeting_user_id) + SELECT $1, $2, $3, $4, $5 + FROM poll_check p, ballot_check b + WHERE p.poll_status = 'POLL_VALID' AND b.ballot_status = 'BALLOT_OK' + RETURNING id + ) + SELECT + CASE + WHEN i.id IS NOT NULL THEN 'VALID' + WHEN p.poll_status != 'POLL_VALID' THEN p.poll_status + WHEN b.ballot_status != 'BALLOT_OK' THEN b.ballot_status + ELSE 'UNKNOWN_ERROR' + END as status + FROM poll_check p, ballot_check b + LEFT JOIN inserted i ON true;` + + var status string + err = v.querier.QueryRow(ctx, sql, pollID, ballotValue, weight, actingMeetingUserID, representedMeetingUserID).Scan( + &status, + ) + if err != nil { + return fmt.Errorf("insert ballot: %w", err) } - // Third database request to get all delegated user ids. Only fetches data - // if there are delegates. - if err := ds.Execute(ctx); err != nil { - return fmt.Errorf("preloading delegate user ids: %w", err) + switch status { + case "VALID": + return nil + case "POLL_NOT_FOUND": + return MessageErrorf(ErrNotExists, "Poll %d does not exist", pollID) + case "POLL_NOT_STARTED": + return MessageErrorf(ErrNotStarted, "Poll %d is not started", pollID) + case "USER_HAS_VOTED_BEFORE": + return MessageErrorf(ErrDoubleVote, "You can not vote again on poll %d", pollID) + default: + return fmt.Errorf("unknown vote sql status %s", status) } +} - for _, uID := range userIDs { - ds.User_DefaultVoteWeight(*uID).Lazy(&dummyDecimal) - ds.User_MeetingUserIDs(*uID).Lazy(&dummyIntSlice) - ds.User_IsPresentInMeetingIDs(*uID).Lazy(&dummyIntSlice) +// EncodeConfig encodes the configuration of a poll into a string. +func (v *Vote) EncodeConfig(ctx context.Context, poll dsmodels.Poll) (string, error) { + configCollection, configIDStr, found := strings.Cut(poll.ConfigID, "/") + if !found { + return "", fmt.Errorf("poll %d has an invalid config_id: %s", poll.ID, poll.ConfigID) } - for _, uID := range delegatedUserIDs { - ds.User_IsPresentInMeetingIDs(uID).Lazy(&dummyIntSlice) - ds.User_MeetingUserIDs(uID).Lazy(&dummyIntSlice) + + configID, err := strconv.Atoi(configIDStr) + if err != nil { + return "", fmt.Errorf("poll %d ha san invalid config_id. Second part is not a number: %s", poll.ID, poll.ConfigID) } - // Thrid or forth database request to get is present_in_meeting for all users and delegates. - if err := ds.Execute(ctx); err != nil { - return fmt.Errorf("preloading user data: %w", err) + dsm := dsmodels.New(v.flow) + var config any + + switch configCollection { + case "poll_config_approval": + configDB, err := dsm.PollConfigApproval(configID).First(ctx) + if err != nil { + return "", fmt.Errorf("fetching poll_config_approval: %w", err) + } + + config = methodApprovalConfig{ + AllowAbstain: dsfetch.MaybeValue(configDB.AllowAbstain), + } + + case "poll_config_selection": + configDB, err := dsm.PollConfigSelection(configID).First(ctx) + if err != nil { + return "", fmt.Errorf("fetching poll_config_selection: %w", err) + } + + config = methodSelectionConfig{ + Options: configDB.OptionIDs, + MaxOptionsAmount: maybeZeroIsNull(configDB.MaxOptionsAmount), + MinOptionsAmount: maybeZeroIsNull(configDB.MinOptionsAmount), + AllowNota: configDB.AllowNota, + } + + case "poll_config_rating_score": + configDB, err := dsm.PollConfigRatingScore(configID).First(ctx) + if err != nil { + return "", fmt.Errorf("fetching poll_config_rating_score: %w", err) + } + + config = methodRatingScoreConfig{ + Options: configDB.OptionIDs, + MaxOptionsAmount: maybeZeroIsNull(configDB.MaxOptionsAmount), + MinOptionsAmount: maybeZeroIsNull(configDB.MinOptionsAmount), + MaxVotesPerOption: maybeZeroIsNull(configDB.MaxVotesPerOption), + MaxVoteSum: maybeZeroIsNull(configDB.MaxVoteSum), + MinVoteSum: maybeZeroIsNull(configDB.MinVoteSum), + } + + case "poll_config_rating_approval": + configDB, err := dsm.PollConfigRatingApproval(configID).First(ctx) + if err != nil { + return "", fmt.Errorf("fetching poll_config_rating_approval: %w", err) + } + + config = methodRatingApprovalConfig{ + Options: configDB.OptionIDs, + MaxOptionsAmount: maybeZeroIsNull(configDB.MaxOptionsAmount), + MinOptionsAmount: maybeZeroIsNull(configDB.MinOptionsAmount), + AllowAbstain: dsfetch.MaybeValue(configDB.AllowAbstain), + } + + default: + panic(fmt.Sprintf("poll %d has an unknown poll config: %s", poll.ID, poll.ConfigID)) } - return nil -} + encoded, err := json.Marshal(config) + if err != nil { + return "", fmt.Errorf("encoding config: %w", err) + } -type maybeInt struct { - unmarshalled bool - value int + return string(encoded), nil } -func (m *maybeInt) UnmarshalJSON(b []byte) error { - if err := json.Unmarshal(b, &m.value); err != nil { - return fmt.Errorf("decoding value as int: %w", err) +func pollMethod(poll dsmodels.Poll) string { + configCollection, _, found := strings.Cut(poll.ConfigID, "/") + if !found { + panic(fmt.Sprintf("poll %d has an invalid config_id: %s", poll.ID, poll.ConfigID)) + } + + switch configCollection { + case "poll_config_approval": + return "approval" + case "poll_config_selection": + return "selection" + case "poll_config_rating_score": + return "rating_score" + case "poll_config_rating_approval": + return "rating_approval" + default: + panic(fmt.Sprintf("poll %d has an unknown poll config: %s", poll.ID, poll.ConfigID)) } - m.unmarshalled = true - return nil } -func (m *maybeInt) Value() (int, bool) { - return m.value, m.unmarshalled -} +// split split sa vote and valides the weight +func split(maxWeight decimal.Decimal, value json.RawMessage) (map[decimal.Decimal]json.RawMessage, error) { + var splitVotes map[decimal.Decimal]json.RawMessage + if err := json.Unmarshal(value, &splitVotes); err != nil { + return nil, errors.Join(MessageError(ErrInvalid, "Invalid split votes"), err) + } + + var splitWeightSum decimal.Decimal + for splitWeight := range splitVotes { + splitWeightSum = splitWeightSum.Add(splitWeight) + } -type ballot struct { - UserID maybeInt `json:"user_id"` - Value ballotValue `json:"value"` + if splitWeightSum.Cmp(maxWeight) == 1 { + return nil, MessageError(ErrInvalid, "Split weight exceeds your vote weight.") + } + + return splitVotes, nil } -func (v ballot) String() string { - bs, err := json.Marshal(v) +// allowedToVote checks, that the represented user can vote and the acting user +// can vote for him. +func allowedToVote( + ctx context.Context, + ds *dsfetch.Fetch, + poll dsmodels.Poll, + representedMeetingUserID int, + actingMeetingUserID int, +) error { + if representedMeetingUserID == 0 { + return MessageError(ErrNotAllowed, "You can not vote for anonymous.") + } + + if err := ensurePresent(ctx, ds, actingMeetingUserID); err != nil { + return fmt.Errorf("ensure acting user %d is present: %w", actingMeetingUserID, err) + } + + groupIDs, err := ds.MeetingUser_GroupIDs(representedMeetingUserID).Value(ctx) if err != nil { - return fmt.Sprintf("Error decoding ballot: %v", err) + return fmt.Errorf("fetching groups of meeting_user %d: %w", representedMeetingUserID, err) } - return string(bs) -} -func validate(poll dsmodels.Poll, v ballotValue) string { - if poll.MinVotesAmount == 0 { - poll.MinVotesAmount = 1 + if !hasCommon(groupIDs, poll.EntitledGroupIDs) { + return MessageErrorf(ErrNotAllowed, "Meeting User %d is not allowed to vote. He is not in an entitled group", representedMeetingUserID) } - if poll.MaxVotesPerOption == 0 { - poll.MaxVotesPerOption = 1 + delegationActivated, err := ds.Meeting_UsersEnableVoteDelegations(poll.MeetingID).Value(ctx) + if err != nil { + return fmt.Errorf("fetching meeting/user_enable_vote_delegations: %w", err) } - allowedOptions := make(map[int]bool, len(poll.OptionIDs)) - for _, o := range poll.OptionIDs { - allowedOptions[o] = true + forbitDelegateToVote, err := ds.Meeting_UsersForbidDelegatorToVote(poll.MeetingID).Value(ctx) + if err != nil { + return fmt.Errorf("fetching meeting/users_forbid_delegator_to_vote: %w", err) } - allowedGlobal := map[string]bool{ - "Y": poll.GlobalYes, - "N": poll.GlobalNo, - "A": poll.GlobalAbstain, + delegation, err := ds.MeetingUser_VoteDelegatedToID(representedMeetingUserID).Value(ctx) + if err != nil { + return fmt.Errorf("fetching meeting_user/vote_delegated_to_id: %w", err) } - var voteIsValid string + if delegationActivated && forbitDelegateToVote && !delegation.Null() && representedMeetingUserID == actingMeetingUserID { + return MessageError(ErrNotAllowed, "You have delegated your vote and therefore can not vote for your self") + } - switch poll.Pollmethod { - case "Y", "N": - switch v.Type() { - case ballotValueString: - // The user answered with Y, N or A (or another invalid string). - if !allowedGlobal[v.str] { - return fmt.Sprintf("Global vote %s is not enabled", v.str) - } - return voteIsValid + if representedMeetingUserID == actingMeetingUserID { + return nil + } - case ballotValueOptionAmount: - if poll.MaxVotesAmount == 0 { - poll.MaxVotesAmount = 1 - } + if !delegationActivated { + return MessageErrorf(ErrNotAllowed, "Vote delegation is not activated in meeting %d", poll.MeetingID) + } - var sumAmount int - for optionID, amount := range v.optionAmount { - if amount < 0 { - return fmt.Sprintf("Your vote for option %d has to be >= 0", optionID) - } + if id, ok := delegation.Value(); !ok || id != actingMeetingUserID { + return MessageErrorf(ErrNotAllowed, "You can not vote for meeting user %d", representedMeetingUserID) + } - if amount > poll.MaxVotesPerOption { - return fmt.Sprintf("Your vote for option %d has to be <= %d", optionID, poll.MaxVotesPerOption) - } + return nil +} - if !allowedOptions[optionID] { - return fmt.Sprintf("Option_id %d does not belong to the poll", optionID) - } +// CalcVoteWeight calculates the vote weight for a user in a meeting. +// +// voteweight is a DecimalField with 6 zeros. +func CalcVoteWeight(ctx context.Context, fetch *dsfetch.Fetch, meetingUserID int) (decimal.Decimal, error) { + defaultVoteWeight, _ := decimal.NewFromString("1.000000") + userID, err := fetch.MeetingUser_UserID(meetingUserID).Value(ctx) + if err != nil { + return decimal.Decimal{}, fmt.Errorf("getting user ID from meeting user: %w", err) + } - sumAmount += amount - } + meetingID, err := fetch.MeetingUser_MeetingID(meetingUserID).Value(ctx) + if err != nil { + return decimal.Decimal{}, fmt.Errorf("getting meeting ID from meeting user: %w", err) + } - if sumAmount < poll.MinVotesAmount || sumAmount > poll.MaxVotesAmount { - return fmt.Sprintf("The sum of your answers has to be between %d and %d", poll.MinVotesAmount, poll.MaxVotesAmount) - } + var voteWeightEnabled bool + var meetingUserVoteWeight decimal.Decimal + var userDefaultVoteWeight decimal.Decimal + fetch.Meeting_UsersEnableVoteWeight(meetingID).Lazy(&voteWeightEnabled) + fetch.MeetingUser_VoteWeight(meetingUserID).Lazy(&meetingUserVoteWeight) + fetch.User_DefaultVoteWeight(userID).Lazy(&userDefaultVoteWeight) - return voteIsValid + if err := fetch.Execute(ctx); err != nil { + return decimal.Decimal{}, fmt.Errorf("getting vote weight values from db: %w", err) + } - default: - return "Your vote has a wrong format" - } + if !voteWeightEnabled { + return defaultVoteWeight, nil + } - case "YN", "YNA": - if poll.MaxVotesAmount == 0 { - poll.MaxVotesAmount = len(poll.OptionIDs) - } - switch v.Type() { - case ballotValueString: - // The user answered with Y, N or A (or another invalid string). - if !allowedGlobal[v.str] { - return fmt.Sprintf("Global vote %s is not enabled", v.str) - } - return voteIsValid + if !meetingUserVoteWeight.IsZero() { + return meetingUserVoteWeight, nil + } - case ballotValueOptionString: - if len(v.optionYNA) < poll.MinVotesAmount || len(v.optionYNA) > poll.MaxVotesAmount { - return fmt.Sprintf("You have to select between %d and %d options", poll.MinVotesAmount, poll.MaxVotesAmount) - } + if !userDefaultVoteWeight.IsZero() { + return userDefaultVoteWeight, nil + } - for optionID, yna := range v.optionYNA { - if !allowedOptions[optionID] { - return fmt.Sprintf("Option_id %d does not belong to the poll", optionID) - } + return defaultVoteWeight, nil +} - if yna != "Y" && yna != "N" && (yna != "A" || poll.Pollmethod != "YNA") { - // Valid that given data matches poll method. - return fmt.Sprintf("Data for option %d does not fit the poll method.", optionID) - } - } - return voteIsValid +// ValidateBallot checks, if a vote is invalid. +func ValidateBallot(method string, config string, vote json.RawMessage) error { + switch method { + case methodApproval{}.Name(): + return methodApproval{}.ValidateVote(config, vote) + case methodSelection{}.Name(): + return methodSelection{}.ValidateVote(config, vote) + case methodRatingScore{}.Name(): + return methodRatingScore{}.ValidateVote(config, vote) + case methodRatingApproval{}.Name(): + return methodRatingApproval{}.ValidateVote(config, vote) + default: + return fmt.Errorf("unknown poll method: %s", method) + } +} - default: - return "Your vote has a wrong format" +// CreateResult creates the result from a list of votes. +func CreateResult(method string, config string, allowVoteSplit bool, ballots []dsmodels.Ballot) (string, error) { + if allowVoteSplit { + ballots = splitVote(method, config, ballots) + } + + switch method { + case methodApproval{}.Name(): + return methodApproval{}.Result(config, ballots) + case methodSelection{}.Name(): + return methodSelection{}.Result(config, ballots) + case methodRatingScore{}.Name(): + return methodRatingScore{}.Result(config, ballots) + case methodRatingApproval{}.Name(): + return methodRatingApproval{}.Result(config, ballots) + default: + return "", fmt.Errorf("unknown poll method: %s", method) + } +} + +func splitVote(method string, config string, ballots []dsmodels.Ballot) []dsmodels.Ballot { + var splittedBallots []dsmodels.Ballot + for _, ballot := range ballots { + if !ballot.Split { + splittedBallots = append(splittedBallots, ballot) + continue } - default: - return "Your vote has a wrong format" + splitted, err := split(ballot.Weight, json.RawMessage(ballot.Value)) + if err != nil { + // If the ballot value can not be splitted, just use it as value. + // It will probably be counted as invalid. + splittedBallots = append(splittedBallots, ballot) + continue + } + + splittedBallots = append(splittedBallots, ballotsFromSplitted(method, config, ballot, splitted)...) } + return splittedBallots } -// voteData is the data a user sends as his vote. -type ballotValue struct { - str string - optionAmount map[int]int - optionYNA map[int]string +func ballotsFromSplitted(method string, config string, ballot dsmodels.Ballot, splitted map[decimal.Decimal]json.RawMessage) []dsmodels.Ballot { + var fromThisBallot []dsmodels.Ballot + for splitWeight, splitValue := range splitted { + if err := ValidateBallot(method, config, splitValue); err != nil { + return []dsmodels.Ballot{ballot} + } - original json.RawMessage + fromThisBallot = append(fromThisBallot, dsmodels.Ballot{ + PollID: ballot.PollID, + Weight: splitWeight, + Value: string(splitValue), + ActingMeetingUserID: ballot.ActingMeetingUserID, + RepresentedMeetingUserID: ballot.RepresentedMeetingUserID, + Split: true, + }) + } + return fromThisBallot } -func (v ballotValue) MarshalJSON() ([]byte, error) { - return v.original, nil +func fetchPoll(ctx context.Context, getter flow.Getter, pollID int) (dsmodels.Poll, error) { + ds := dsmodels.New(getter) + poll, err := ds.Poll(pollID).First(ctx) + if err != nil { + var doesNotExist dsfetch.DoesNotExistError + if errors.As(err, &doesNotExist) { + return dsmodels.Poll{}, MessageErrorf(ErrNotExists, "Poll %d does not exist", pollID) + } + return dsmodels.Poll{}, fmt.Errorf("loading poll %d: %w", pollID, err) + } + + return poll, nil } -func (v *ballotValue) UnmarshalJSON(b []byte) error { - v.original = b +func getMeetingUser(ctx context.Context, fetch *dsfetch.Fetch, userID, meetingID int) (int, bool, error) { + meetingUserIDs, err := fetch.User_MeetingUserIDs(userID).Value(ctx) + if err != nil { + return 0, false, fmt.Errorf("getting all meeting_user ids: %w", err) + } - if err := json.Unmarshal(b, &v.str); err == nil { - // voteData is a string - return nil + meetingIDs := make([]int, len(meetingUserIDs)) + for i := range meetingUserIDs { + fetch.MeetingUser_MeetingID(meetingUserIDs[i]).Lazy(&meetingIDs[i]) } - if err := json.Unmarshal(b, &v.optionAmount); err == nil { - // voteData is option_id to amount - return nil + if err := fetch.Execute(ctx); err != nil { + return 0, false, fmt.Errorf("get all meeting IDs: %w", err) } - v.optionAmount = nil - if err := json.Unmarshal(b, &v.optionYNA); err == nil { - // voteData is option_id to string - return nil + idx := slices.Index(meetingIDs, meetingID) + if idx == -1 { + return 0, false, nil } - return fmt.Errorf("unknown vote value: `%s`", b) + return meetingUserIDs[idx], true, nil } -const ( - ballotValueUnknown = iota - ballotValueString - ballotValueOptionAmount - ballotValueOptionString -) +func canManagePoll(ctx context.Context, getter flow.Getter, meetingID int, contentObjectID string, userID int) error { + collection, _, found := strings.Cut(contentObjectID, "/") + if !found { + return fmt.Errorf("invalid content object id: %s", contentObjectID) + } -func (v *ballotValue) Type() int { - if v.str != "" { - return ballotValueString + var requiredPerm perm.TPermission + switch collection { + case "motion": + requiredPerm = perm.MotionCanManagePolls + case "assignment": + requiredPerm = perm.AssignmentCanManagePolls + case "topic": + requiredPerm = perm.PollCanManage + default: + return fmt.Errorf( + "invalid content object id %s, only motion, assignment or topic allowed", + contentObjectID, + ) } - if v.optionAmount != nil { - return ballotValueOptionAmount + userPerms, err := perm.New(ctx, dsfetch.New(getter), userID, meetingID) + if err != nil { + return fmt.Errorf("calculate user permissions: %w", err) } - if v.optionYNA != nil { - return ballotValueOptionString + if !userPerms.Has(requiredPerm) { + return MessageError(ErrNotAllowed, "You are not allowed to manage a poll") } - return ballotValueUnknown + return nil } -// equalElement returns true, if g1 and g2 have at lease one equal element. -func equalElement(g1, g2 []int) bool { - set := make(map[int]bool, len(g1)) - for _, e := range g1 { - set[e] = true +func ensurePresent(ctx context.Context, ds *dsfetch.Fetch, meetingUser int) error { + meetingID, err := ds.MeetingUser_MeetingID(meetingUser).Value(ctx) + if err != nil { + return fmt.Errorf("fetching meeting ID: %w", err) } - for _, e := range g2 { - if set[e] { - return true + + userID, err := ds.MeetingUser_UserID(meetingUser).Value(ctx) + if err != nil { + return fmt.Errorf("fetching user ID: %w", err) + } + + presentMeetings, err := ds.User_IsPresentInMeetingIDs(userID).Value(ctx) + if err != nil { + return fmt.Errorf("fetching is present in meetings: %w", err) + } + + if !slices.Contains(presentMeetings, meetingID) { + return MessageErrorf(ErrNotAllowed, "You have to be present in meeting %d", meetingID) + } + + return nil +} + +func hasCommon(list1, list2 []int) bool { + return slices.ContainsFunc(list1, func(a int) bool { + return slices.Contains(list2, a) + }) +} + +// Preload loads all data in the cache, that is needed later for the vote +// requests. +func Preload(ctx context.Context, flow flow.Getter, pollID int, meetingID int) error { + ds := dsmodels.New(flow) + var dummyBool bool + ds.Meeting_UsersEnableVoteWeight(meetingID).Lazy(&dummyBool) + ds.Meeting_UsersEnableVoteDelegations(meetingID).Lazy(&dummyBool) + ds.Meeting_UsersForbidDelegatorToVote(meetingID).Lazy(&dummyBool) + + q := ds.Poll(pollID) + q = q.Preload(q.EntitledGroupList().MeetingUserList().User()) + q = q.Preload(q.EntitledGroupList().MeetingUserList().VoteDelegatedTo().User()) + poll, err := q.First(ctx) + if err != nil { + return fmt.Errorf("fetch preload data: %w", err) + } + + configCollection, configIDStr, found := strings.Cut(poll.ConfigID, "/") + if !found { + return fmt.Errorf("invalid value in configID: %s", poll.ConfigID) + } + + configID, err := strconv.Atoi(configIDStr) + if err != nil { + return fmt.Errorf("invalid value in configID. Second part has to be an int: %s", poll.ConfigID) + } + + switch configCollection { + case "poll_config_approval": + _, err := ds.PollConfigApproval(configID).First(ctx) + if err != nil { + return fmt.Errorf("fetch poll config approval: %w", err) } + + case "poll_config_selection": + _, err := ds.PollConfigSelection(configID).First(ctx) + if err != nil { + return fmt.Errorf("fetch poll config selection: %w", err) + } + + case "poll_config_rating_score": + _, err := ds.PollConfigRatingScore(configID).First(ctx) + if err != nil { + return fmt.Errorf("fetch poll config rating score: %w", err) + } + + case "poll_config_rating_approval": + _, err := ds.PollConfigRatingApproval(configID).First(ctx) + if err != nil { + return fmt.Errorf("fetch poll config rating approval: %w", err) + } + + default: + return fmt.Errorf("invalid config collection. Unknown method: %s", configCollection) + + } + + return nil +} + +// DBQuerier is either a pgx-connection or a pgx-pool. +type DBQuerier interface { + Begin(ctx context.Context) (pgx.Tx, error) + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row + Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) +} + +func maybeZeroIsNull(n int) dsfetch.Maybe[int] { + if n == 0 { + return dsfetch.Maybe[int]{} + } + + return dsfetch.MaybeValue(n) +} + +func valueOrZero(n dsfetch.Maybe[int]) int { + value, set := n.Value() + if set { + return value } - return false + return 0 } diff --git a/vote/vote_preload_test.go b/vote/vote_preload_test.go index 5a6b7ce1..515e39f8 100644 --- a/vote/vote_preload_test.go +++ b/vote/vote_preload_test.go @@ -1,20 +1,222 @@ -package vote +package vote_test import ( - "bytes" - "context" - "fmt" + "slices" + "strings" "testing" + "github.com/OpenSlides/openslides-go/datastore/cache" "github.com/OpenSlides/openslides-go/datastore/dsfetch" "github.com/OpenSlides/openslides-go/datastore/dsmock" "github.com/OpenSlides/openslides-go/datastore/dsmodels" + "github.com/OpenSlides/openslides-go/datastore/pgtest" + "github.com/OpenSlides/openslides-vote-service/vote" ) +func TestVoteNoRequests(t *testing.T) { + // This tests makes sure, that a request to vote does not do any reading + // from the database. All values have to be in the cache from pollpreload. + + t.Parallel() + + if testing.Short() { + t.Skip("Postgres Test") + } + + ctx := t.Context() + + pg, err := pgtest.NewPostgresTest(ctx) + if err != nil { + t.Fatalf("Error starting postgres: %v", err) + } + defer pg.Close() + + baseData := ` + meeting/1/users_enable_vote_delegations: true + + motion/5: + meeting_id: 1 + sequential_number: 1 + title: my motion + state_id: 1 + + list_of_speakers/7: + content_object_id: motion/5 + sequential_number: 1 + meeting_id: 1 + + group/40: + name: delegates + meeting_id: 1 + + user: + 5: + username: admin + organization_management_level: superadmin + 30: + username: tom + + 40: + username: georg + + meeting_user: + 31: + user_id: 30 + meeting_id: 1 + + 41: + user_id: 40 + meeting_id: 1 + + poll/5: + title: normal poll + config_id: poll_config_approval/77 + visibility: open + sequential_number: 1 + content_object_id: motion/5 + meeting_id: 1 + state: created + entitled_group_ids: [40] + + poll_config_approval/77: + poll_id: 5 + allow_abstain: true + ` + + for _, tt := range []struct { + name string + data string + vote string + expectRepresentedMeetingUserID int + }{ + { + "normal vote", + `--- + user/30: + is_present_in_meeting_ids: [1] + + meeting_user/31: + group_ids: [40] + + `, + `{"value":"Yes"}`, + 31, + }, + { + "delegation vote", + `--- + user/30: + is_present_in_meeting_ids: [1] + + meeting_user: + 41: + group_ids: [40] + vote_delegated_to_id: 31 + + `, + `{"meeting_user_id":41,"value":"Yes"}`, + 41, + }, + { + "vote weight enabled", + `--- + user/30: + is_present_in_meeting_ids: [1] + + meeting_user/31: + group_ids: [40] + + meeting/1: + users_enable_vote_weight: true + `, + `{"value":"Yes"}`, + 31, + }, + { + "vote weight enabled and delegated", + `--- + meeting/1: + users_enable_vote_weight: true + + user/30: + is_present_in_meeting_ids: [1] + + meeting_user: + 41: + group_ids: [40] + vote_delegated_to_id: 31 + `, + `{"meeting_user_id":41,"value":"Yes"}`, + 41, + }, + } { + t.Run(tt.name, func(t *testing.T) { + pg.Cleanup(t) + + if err := pg.AddData(ctx, baseData); err != nil { + t.Fatalf("Error: Insert base data: %v", err) + } + + if err := pg.AddData(ctx, tt.data); err != nil { + t.Fatalf("Error: Inserting data: %v", err) + } + + flow, err := pg.Flow() + if err != nil { + t.Fatalf("Error getting flow: %v", err) + } + defer flow.Close() + + counter := dsmock.NewCounterFlow(flow) + cache := cache.New(counter) + + conn, err := pg.Conn(ctx) + if err != nil { + t.Fatalf("Error getting connection: %v", err) + } + defer conn.Close(ctx) + + service, _, err := vote.New(ctx, cache, conn) + if err != nil { + t.Fatalf("Error creating vote: %v", err) + } + + if err := service.Start(ctx, 5, 5); err != nil { + t.Fatalf("Start poll: %v", err) + } + counter.Reset() + + if err := service.Vote(ctx, 5, 30, strings.NewReader(tt.vote)); err != nil { + t.Fatalf("Vote returned unexpected error: %v", err) + } + + if counter.Count() != 0 { + t.Errorf("Vote send %d requests to the datastore:\n%s", counter.Count(), counter.PrintRequests()) + } + + ds := dsmodels.New(counter) // Use the counter here to skip the cache + q := ds.Poll(5) + q = q.Preload(q.BallotList()) + poll, err := q.First(ctx) + if err != nil { + t.Fatalf("Error: Getting votes from poll: %v", err) + } + found := slices.ContainsFunc(poll.BallotList, func(ballot dsmodels.Ballot) bool { + meetingUserID, _ := ballot.RepresentedMeetingUserID.Value() + return meetingUserID == tt.expectRepresentedMeetingUserID + }) + + if !found { + t.Errorf("user %d has not voted", tt.expectRepresentedMeetingUserID) + } + }) + } +} + func TestPreload(t *testing.T) { // Tests, that the preload function needs a specific number of requests to // postgres. - ctx := context.Background() + ctx := t.Context() for _, tt := range []struct { name string @@ -24,166 +226,282 @@ func TestPreload(t *testing.T) { { "one user", `--- - meeting/5/id: 5 - poll/1: - meeting_id: 5 - entitled_group_ids: [30] - pollmethod: Y - global_yes: true - backend: fast - type: pseudoanonymous - content_object_id: some_field/1 + meeting/1/users_enable_vote_delegations: true + + motion/5: + meeting_id: 1 sequential_number: 1 - onehundred_percent_base: base - title: myPoll + title: my motion + state_id: 1 - group/30/meeting_user_ids: [500] + list_of_speakers/7: + content_object_id: motion/5 + sequential_number: 1 + meeting_id: 1 - user/50: - is_present_in_meeting_ids: [5] + group/40: + name: delegates + meeting_id: 1 + meeting_user_ids: [30] - meeting_user/500: - group_ids: [31] - user_id: 50 - meeting_id: 5 + user: + 5: + username: admin + organization_management_level: superadmin + 30: + organization_id: 1 + username: tom + + meeting_user: + 30: + user_id: 30 + meeting_id: 1 + group_ids: 40 + + poll/5: + title: normal poll + config_id: poll_config_approval/77 + visibility: open + sequential_number: 1 + content_object_id: motion/5 + meeting_id: 1 + state: created + entitled_group_ids: [40] + + poll_config_approval/77: + poll_id: 5 + allow_abstain: true `, - 3, + 5, }, { "Many groups", `--- - meeting/5/id: 5 - poll/1: - meeting_id: 5 - entitled_group_ids: [30,31] - pollmethod: Y - global_yes: true - backend: fast - type: pseudoanonymous - content_object_id: some_field/1 + meeting/1/users_enable_vote_delegations: true + + motion/5: + meeting_id: 1 + sequential_number: 1 + title: my motion + state_id: 1 + + list_of_speakers/7: + content_object_id: motion/5 sequential_number: 1 - onehundred_percent_base: base - title: myPoll + meeting_id: 1 - group/30/meeting_user_ids: [500] - group/31/meeting_user_ids: [500] + group/40: + name: delegates + meeting_id: 1 + meeting_user_ids: [30] + group/41: + name: delegates2 + meeting_id: 1 + meeting_user_ids: [30] user: - 50: - is_present_in_meeting_ids: [5] + 5: + username: admin + organization_management_level: superadmin + organization_id: 1 + 30: + username: tom + organization_id: 1 - meeting_user/500: - user_id: 50 - group_ids: [30] - meeting_id: 5 + meeting_user: + 30: + user_id: 30 + meeting_id: 1 + group_ids: 40 + + poll/5: + title: normal poll + config_id: poll_config_approval/77 + visibility: open + sequential_number: 1 + content_object_id: motion/5 + meeting_id: 1 + state: created + entitled_group_ids: [40,41] + + poll_config_approval/77: + poll_id: 5 + allow_abstain: true `, - 3, + 5, }, { "Many users", `--- - meeting/5/id: 5 - poll/1: - meeting_id: 5 - entitled_group_ids: [30] - pollmethod: Y - global_yes: true - backend: fast - type: pseudoanonymous - content_object_id: some_field/1 + meeting/1/users_enable_vote_delegations: true + + motion/5: + meeting_id: 1 sequential_number: 1 - onehundred_percent_base: base - title: myPoll + title: my motion + state_id: 1 - group/30/meeting_user_ids: [500,510] + list_of_speakers/7: + content_object_id: motion/5 + sequential_number: 1 + meeting_id: 1 - user: - 50: - is_present_in_meeting_ids: [5] + group/40: + name: delegates + meeting_id: 1 + meeting_user_ids: [30,31] - 51: - is_present_in_meeting_ids: [5] + user: + 5: + username: admin + organization_management_level: superadmin + organization_id: 1 + 30: + username: tom + organization_id: 1 + 31: + username: gregor + organization_id: 1 meeting_user: - 500: - user_id: 50 - meeting_id: 5 - 510: - user_id: 51 - meeting_id: 5 + 30: + user_id: 30 + meeting_id: 1 + group_ids: 40 + 31: + user_id: 30 + meeting_id: 1 + group_ids: 40 + + poll/5: + title: normal poll + config_id: poll_config_approval/77 + visibility: open + sequential_number: 1 + content_object_id: motion/5 + meeting_id: 1 + state: created + entitled_group_ids: [40] + + poll_config_approval/77: + poll_id: 5 + allow_abstain: true `, - 3, + 5, }, { "Many users in different groups", `--- - meeting/5/id: 5 - poll/1: - meeting_id: 5 - entitled_group_ids: [30, 31] - pollmethod: Y - global_yes: true - backend: fast - type: pseudoanonymous - content_object_id: some_field/1 + meeting/1/users_enable_vote_delegations: true + + motion/5: + meeting_id: 1 sequential_number: 1 - onehundred_percent_base: base - title: myPoll + title: my motion + state_id: 1 - group/30/meeting_user_ids: [500] - group/31/meeting_user_ids: [510] + list_of_speakers/7: + content_object_id: motion/5 + sequential_number: 1 + meeting_id: 1 - user: - 50: - is_present_in_meeting_ids: [5] + group/40: + name: delegates + meeting_id: 1 + meeting_user_ids: [30] - 51: - is_present_in_meeting_ids: [5] + group/41: + name: delegates + meeting_id: 1 + meeting_user_ids: [31] + + user: + 5: + username: admin + organization_management_level: superadmin + organization_id: 1 + 30: + username: tom + organization_id: 1 + 31: + username: gregor + organization_id: 1 meeting_user: - 500: - user_id: 50 - meeting_id: 5 - 510: - user_id: 51 - meeting_id: 5 + 30: + user_id: 30 + meeting_id: 1 + group_ids: 40 + 31: + user_id: 30 + meeting_id: 1 + group_ids: 41 + + poll/5: + title: normal poll + config_id: poll_config_approval/77 + visibility: open + sequential_number: 1 + content_object_id: motion/5 + meeting_id: 1 + state: created + entitled_group_ids: [40,41] + + poll_config_approval/77: + poll_id: 5 + allow_abstain: true `, - 3, + 5, }, { "Many users in different groups with delegation", `--- - meeting/5/id: 5 - poll/1: - meeting_id: 5 - entitled_group_ids: [30, 31] - pollmethod: Y - global_yes: true - backend: fast - type: pseudoanonymous - content_object_id: some_field/1 + meeting/1/users_enable_vote_delegations: true + + motion/5: + meeting_id: 1 + sequential_number: 1 + title: my motion + state_id: 1 + + list_of_speakers/7: + content_object_id: motion/5 sequential_number: 1 - onehundred_percent_base: base - title: myPoll + meeting_id: 1 - group/30/meeting_user_ids: [500] - group/31/meeting_user_ids: [510] + group/40: + name: delegates + meeting_id: 1 + meeting_user_ids: [500] + + group/41: + name: delegates + meeting_id: 1 + meeting_user_ids: [510] user: 50: + username: user50 + organization_id: 1 is_present_in_meeting_ids: [5] 51: + username: user51 + organization_id: 1 is_present_in_meeting_ids: [5] 52: + username: user52 + organization_id: 1 is_present_in_meeting_ids: [5] 53: + username: user53 + organization_id: 1 is_present_in_meeting_ids: [5] meeting_user: @@ -201,32 +519,34 @@ func TestPreload(t *testing.T) { 530: user_id: 53 meeting_id: 5 + + poll/5: + title: normal poll + config_id: poll_config_approval/77 + visibility: open + sequential_number: 1 + content_object_id: motion/5 + meeting_id: 1 + state: created + entitled_group_ids: [40,41] + + poll_config_approval/77: + poll_id: 5 + allow_abstain: true `, - 4, + 6, }, } { t.Run(tt.name, func(t *testing.T) { dsCount := dsmock.NewCounter(dsmock.Stub(dsmock.YAMLData(tt.data))) ds := dsmock.NewCache(dsCount) - fetcher := dsmodels.New(ds) - - poll, err := fetcher.Poll(1).First(ctx) - if err != nil { - t.Fatalf("loadPoll returned: %v", err) - } - - dsCount.(*dsmock.Counter).Reset() - if err := preload(ctx, dsfetch.New(ds), poll); err != nil { + if err := vote.Preload(ctx, dsfetch.New(ds), 5, 1); err != nil { t.Errorf("preload returned: %v", err) } - if got := dsCount.(*dsmock.Counter).Count(); got != tt.expectCount { - buf := new(bytes.Buffer) - for _, req := range dsCount.(*dsmock.Counter).Requests() { - fmt.Fprintln(buf, req) - } - t.Errorf("preload send %d requests, expected %d:\n%s", got, tt.expectCount, buf) + if got := dsCount.Count(); got != tt.expectCount { + t.Errorf("preload send %d requests, expected %d:\n%s", got, tt.expectCount, dsCount.PrintRequests()) } }) } diff --git a/vote/vote_test.go b/vote/vote_test.go index 943e4c20..f4a166c6 100644 --- a/vote/vote_test.go +++ b/vote/vote_test.go @@ -1,1642 +1,1419 @@ package vote_test import ( - "context" - "encoding/json" "errors" - "reflect" + "slices" "strings" "testing" - "github.com/OpenSlides/openslides-go/datastore/cache" + "github.com/OpenSlides/openslides-go/datastore/dsfetch" + "github.com/OpenSlides/openslides-go/datastore/dskey" "github.com/OpenSlides/openslides-go/datastore/dsmock" - "github.com/OpenSlides/openslides-vote-service/backend/memory" + "github.com/OpenSlides/openslides-go/datastore/dsmodels" + "github.com/OpenSlides/openslides-go/datastore/flow" + "github.com/OpenSlides/openslides-go/datastore/pgtest" "github.com/OpenSlides/openslides-vote-service/vote" ) -func TestVoteStart(t *testing.T) { - ctx := context.Background() +func TestAll(t *testing.T) { + t.Parallel() - t.Run("Unknown poll", func(t *testing.T) { - backend := memory.New() - ds := dsmock.NewFlow(dsmock.YAMLData("")) - v, _, _ := vote.New(ctx, backend, backend, ds, true) + if testing.Short() { + t.Skip("Postgres Test") + } - err := v.Start(ctx, 1) - if !errors.Is(err, vote.ErrNotExists) { - t.Errorf("Start returned unexpected error: %v", err) - } - }) + ctx := t.Context() - t.Run("Not started poll", func(t *testing.T) { - backend := memory.New() - ds := dsmock.NewFlow( - dsmock.YAMLData(` - poll: - 1: - meeting_id: 5 - state: started - backend: fast - type: pseudoanonymous - pollmethod: Y - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll - - user/1/is_present_in_meeting_ids: [1] - meeting/5/id: 5 - `), - dsmock.NewCounter, - ) - counter := ds.Middlewares()[0].(*dsmock.Counter) - - v, _, _ := vote.New(ctx, backend, backend, ds, true) - - if err := v.Start(ctx, 1); err != nil { - t.Errorf("Start returned unexpected error: %v", err) - } + pg, err := pgtest.NewPostgresTest(ctx) + if err != nil { + t.Fatalf("Error starting postgres: %v", err) + } + defer pg.Close() - if c := counter.Count(); c > 2 { - t.Errorf("Start used %d requests to the datastore, expected max 2: %v", c, counter.Requests()) - } + data := `--- + organization/1/enable_electronic_voting: true + motion/5: + meeting_id: 1 + sequential_number: 1 + title: my motion + state_id: 1 - // After a poll was started, it has to be possible to send votes. - if err := backend.Vote(ctx, 1, 1, []byte("something")); err != nil { - t.Errorf("Vote after start retuen and unexpected error: %v", err) - } - }) + list_of_speakers/7: + content_object_id: motion/5 + sequential_number: 1 + meeting_id: 1 - t.Run("Start poll a second time", func(t *testing.T) { - backend := memory.New() - ds := &StubGetter{data: dsmock.YAMLData(` - poll: - 1: - meeting_id: 5 - type: named - state: started - backend: fast - pollmethod: Y - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll + meeting/1: + present_user_ids: [30] + + user: + 5: + username: admin + organization_management_level: superadmin + 30: + username: tom + meeting_user/300: + group_ids: [40] + user_id: 30 + meeting_id: 1 - user/1/is_present_in_meeting_ids: [1] - meeting/5/id: 5 - `)} - v, _, _ := vote.New(ctx, backend, backend, ds, true) - v.Start(ctx, 1) + group/40: + name: delegate + meeting_id: 1 - if err := v.Start(ctx, 1); err != nil { - t.Errorf("Start returned unexpected error: %v", err) - } - }) + group/41: + name: wrong group + meeting_id: 1 + ` + + withData( + t, + pg, + data, + func(service *vote.Vote, flow flow.Flow) { + t.Run("Create", func(t *testing.T) { + body := `{ + "title": "my pol", + "content_object_id": "motion/5", + "method": "approval", + "config": {}, + "visibility": "open", + "meeting_id": 1, + "entitled_group_ids": [41] + }` + + id, err := service.Create(ctx, 5, strings.NewReader(body)) + if err != nil { + t.Fatalf("Error creating poll: %v", err) + } - t.Run("Start a stopped poll", func(t *testing.T) { - backend := memory.New() - ds := &StubGetter{data: dsmock.YAMLData(` - poll: - 1: - meeting_id: 5 - type: named - state: started - backend: fast - pollmethod: Y - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll + if id != 1 { + t.Errorf("Expected id 1, got %d", id) + } + + key := dskey.MustKey("poll/1/title") + result, err := flow.Get(ctx, key) + if err != nil { + t.Fatalf("Error getting title from created poll: %v", err) + } - user/1/is_present_in_meeting_ids: [1] - meeting/5/id: 5 - `)} - v, _, _ := vote.New(ctx, backend, backend, ds, true) - v.Start(ctx, 1) + if string(result[key]) != `"my pol"` { + t.Errorf("Expected title 'my poll', got %s", result[key]) + } + }) - if _, _, err := backend.Stop(ctx, 1); err != nil { - t.Fatalf("Stop returned unexpected error: %v", err) - } + t.Run("Update", func(t *testing.T) { + body := `{ + "title": "my poll", + "entitled_group_ids": [40] + }` - if err := v.Start(ctx, 1); err != nil { - t.Errorf("Start returned unexpected error: %v", err) - } - }) + err := service.Update(ctx, 1, 5, strings.NewReader(body)) + if err != nil { + t.Fatalf("Error creating poll: %v", err) + } - t.Run("Start an anolog poll", func(t *testing.T) { - backend := memory.New() - ds := &StubGetter{data: dsmock.YAMLData(` - poll: - 1: - meeting_id: 5 - type: analog - state: started - backend: fast - pollmethod: Y - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll + poll, err := dsmodels.New(flow).Poll(1).First(ctx) + if err != nil { + t.Fatalf("fetch poll: %v", err) + } - user/1/is_present_in_meeting_ids: [1] - `)} - v, _, _ := vote.New(ctx, backend, backend, ds, true) + if poll.Title != `my poll` { + t.Errorf("Expected title 'my poll', got %s", poll.Title) + } - err := v.Start(ctx, 1) + if len(poll.EntitledGroupIDs) != 1 && poll.EntitledGroupIDs[0] != 40 { + t.Errorf("Expected entitled_group_ids [40], got %v", poll.EntitledGroupIDs) + } + }) - if err == nil { - t.Errorf("Got no error, expected `Some error`") - } - }) + t.Run("Start", func(t *testing.T) { + if err := service.Start(ctx, 1, 5); err != nil { + t.Fatalf("Error starting poll: %v", err) + } - t.Run("Start an poll in `wrong` state", func(t *testing.T) { - backend := memory.New() - ds := &StubGetter{data: dsmock.YAMLData(` - poll: - 1: - meeting_id: 5 - type: named - state: created - backend: fast - pollmethod: Y - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll + key := dskey.MustKey("poll/1/state") + values, err := flow.Get(ctx, key) + if err != nil { + t.Fatalf("Error getting state from poll: %v", err) + } - user/1/is_present_in_meeting_ids: [1] - meeting/5/id: 5 - `)} - v, _, _ := vote.New(ctx, backend, backend, ds, true) + if string(values[key]) != `"started"` { + t.Errorf("Expected state to be started, got %s", values[key]) + } + }) - err := v.Start(ctx, 1) - if err != nil { - t.Errorf("Start returned: %v", err) - } - }) + t.Run("Vote", func(t *testing.T) { + body := `{"value":"Yes"}` + if err := service.Vote(ctx, 1, 30, strings.NewReader(body)); err != nil { + t.Fatalf("Error voting poll: %v", err) + } - t.Run("Start an finished poll", func(t *testing.T) { - backend := memory.New() - ds := &StubGetter{data: dsmock.YAMLData(` - poll: - 1: - meeting_id: 5 - type: named - state: finished - backend: fast - pollmethod: Y - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll + ds := dsmodels.New(flow) + vote, err := ds.Ballot(1).First(t.Context()) + if err != nil { + t.Fatalf("Error: Getting vote: %v", err) + } - user/1/is_present_in_meeting_ids: [1] - `)} - v, _, _ := vote.New(ctx, backend, backend, ds, true) + if id, _ := vote.ActingMeetingUserID.Value(); id != 300 { + t.Errorf("Expected acting meeting_user ID to be 300, got %d", id) + } - err := v.Start(ctx, 1) + if vote.Value != `"Yes"` { + t.Errorf("Expected vote value to be '\"Yes\"', got '%s'", vote.Value) + } + }) - if err == nil { - t.Errorf("Got no error, expected `Some error`") - } - }) + t.Run("Stop", func(t *testing.T) { + if err := service.Finalize(ctx, 1, 5, false, false); err != nil { + t.Fatalf("Error stopping poll: %v", err) + } - t.Run("Start an finished poll", func(t *testing.T) { - backend := memory.New() - ds := &StubGetter{data: dsmock.YAMLData(` - poll: - 1: - meeting_id: 5 - type: named - state: published - backend: fast - pollmethod: Y - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll + keyState := dskey.MustKey("poll/1/state") + keyResult := dskey.MustKey("poll/1/result") + values, err := flow.Get(ctx, keyState, keyResult) + if err != nil { + t.Fatalf("Error getting state from poll: %v", err) + } + + if string(values[keyState]) != `"finished"` { + t.Errorf("Expected state to be finished, got %s", values[keyState]) + } - user/1/is_present_in_meeting_ids: [1] - `)} - v, _, _ := vote.New(ctx, backend, backend, ds, true) + if string(values[keyResult]) == `` { + t.Errorf("Expected result to be set") + } + }) - err := v.Start(ctx, 1) + t.Run("Publish", func(t *testing.T) { + if err := service.Finalize(ctx, 1, 5, true, false); err != nil { + t.Fatalf("Error publishing poll: %v", err) + } - if err == nil { - t.Errorf("Got no error, expected `Some error`") - } - }) + key := dskey.MustKey("poll/1/published") + values, err := flow.Get(ctx, key) + if err != nil { + t.Fatalf("Error getting state from poll: %v", err) + } + + if string(values[key]) != `true` { + t.Errorf("Expected published to be true, got %s", values[key]) + } + }) + + t.Run("Anonymize", func(t *testing.T) { + if err := service.Finalize(ctx, 1, 5, true, true); err != nil { + t.Fatalf("Error anonymizing poll: %v", err) + } + + ds := dsmodels.New(flow) + vote, err := ds.Ballot(1).First(t.Context()) + if err != nil { + t.Fatalf("Error: Getting vote: %v", err) + } + + if id, set := vote.ActingMeetingUserID.Value(); set { + t.Errorf("Expected acting meeting_user ID not to be set, but is is %d", id) + } + }) + + t.Run("Reset", func(t *testing.T) { + if err := service.Reset(ctx, 1, 5); err != nil { + t.Fatalf("Error resetting poll: %v", err) + } + + key := dskey.MustKey("poll/1/state") + values, err := flow.Get(ctx, key) + if err != nil { + t.Fatalf("Error getting state from poll: %v", err) + } + + if string(values[key]) != `"created"` { + t.Errorf("Expected state to be created, got %s", values[key]) + } + }) + + t.Run("Delete", func(t *testing.T) { + if err := service.Delete(ctx, 1, 5); err != nil { + t.Fatalf("Error deleting poll: %v", err) + } + + key := dskey.MustKey("poll/1/title") + _, err := flow.Get(ctx, key) + if err != nil { + t.Fatalf("Error getting title from created poll: %v", err) + } + }) + + }, + ) } -func TestVoteStartDSError(t *testing.T) { - ctx := context.Background() - backend := memory.New() - ds := &StubGetter{err: errors.New("Some error")} - v, _, _ := vote.New(ctx, backend, backend, ds, true) - err := v.Start(ctx, 1) +func TestCreateWithOptions(t *testing.T) { + t.Parallel() - if err == nil { - t.Errorf("Got no error, expected `Some error`") + if testing.Short() { + t.Skip("Postgres Test") } -} -func TestVoteStop(t *testing.T) { - ctx := context.Background() - backend := memory.New() + ctx := t.Context() + + pg, err := pgtest.NewPostgresTest(ctx) + if err != nil { + t.Fatalf("Error starting postgres: %v", err) + } + defer pg.Close() + + data := `--- + organization/1/enable_electronic_voting: true + user: + 5: + username: admin + organization_management_level: superadmin + + 10: + username: user10 + 20: + username: user20 + 30: + username: user30 - ds := &StubGetter{data: dsmock.YAMLData(` - poll: - 1: + meeting_user: + 11: + user_id: 10 meeting_id: 1 - backend: fast - type: pseudoanonymous - pollmethod: Y - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll - 2: + 21: + user_id: 20 meeting_id: 1 - backend: fast - type: pseudoanonymous - pollmethod: Y - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll - 3: + 31: + user_id: 30 meeting_id: 1 - backend: fast - type: pseudoanonymous - pollmethod: Y - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll - `)} - - v, _, _ := vote.New(ctx, backend, backend, ds, true) - - t.Run("Unknown poll", func(t *testing.T) { - _, err := v.Stop(ctx, 404) - if !errors.Is(err, vote.ErrNotExists) { - t.Errorf("Start returned unexpected error: %v", err) - } - }) - t.Run("Unknown poll", func(t *testing.T) { - _, err := v.Stop(ctx, 1) - if !errors.Is(err, vote.ErrNotExists) { - t.Errorf("Stopping an unknown poll has to return an ErrNotExists, got: %v", err) - } - }) + assignment/5: + meeting_id: 1 + sequential_number: 1 + title: my assignment - t.Run("Known poll", func(t *testing.T) { - if err := backend.Start(ctx, 2); err != nil { - t.Fatalf("Start returned an unexpected error: %v", err) - } + list_of_speakers/7: + content_object_id: assignment/5 + sequential_number: 1 + meeting_id: 1 - backend.Vote(ctx, 2, 1, []byte(`"polldata1"`)) - backend.Vote(ctx, 2, 2, []byte(`"polldata2"`)) + meeting/1/welcome_title: hello world + ` - result, err := v.Stop(ctx, 2) + withData(t, pg, data, func(service *vote.Vote, flow flow.Flow) { + body := `{ + "title": "my poll", + "content_object_id": "assignment/5", + "method": "selection", + "config": {"option_type":"meeting_user","options":[31,11,21]}, + "visibility": "open", + "meeting_id": 1 + }` + + id, err := service.Create(ctx, 5, strings.NewReader(body)) if err != nil { - t.Fatalf("Stop returned unexpected error: %v", err) + t.Fatalf("Error creating poll: %v", err) } - expect := [][]byte{[]byte(`"polldata1"`), []byte(`"polldata2"`)} - if !reflect.DeepEqual(result.Votes, expect) { - t.Errorf("Got:\n`%s`, expected\n`%s`", result.Votes, expect) + if id != 1 { + t.Errorf("Expected id 1, got %d", id) } - if !reflect.DeepEqual(result.UserIDs, []int{1, 2}) { - t.Errorf("Got users %s, expected [1 2]", result.Votes) + poll, err := dsmodels.New(flow).Poll(1).First(ctx) + if err != nil { + t.Fatalf("Fetch poll: %v", err) } - err = backend.Vote(ctx, 2, 3, []byte(`"polldata3"`)) - var errStopped interface{ Stopped() } - if !errors.As(err, &errStopped) { - t.Errorf("Stop did not stop the poll in the backend.") + cfg, err := service.EncodeConfig(ctx, poll) + if err != nil { + t.Fatalf("Encode config: %v", err) } - }) - t.Run("Poll without data", func(t *testing.T) { - if err := backend.Start(ctx, 3); err != nil { - t.Fatalf("Start: %v", err) + expected := `{"options":[1,2,3],"max_options_amount":null,"min_options_amount":null,"allow_nota":false}` + if cfg != expected { + t.Errorf("Created config `%s`, expected `%s`", cfg, expected) } - result, err := v.Stop(ctx, 3) + options, err := dsmodels.New(flow).PollConfigOption(1, 2, 3).Get(ctx) if err != nil { - t.Fatalf("Stop: %v", err) + t.Fatalf("Get options: %v", err) } - - if len(result.Votes) != 0 { - t.Errorf("Got votes %v, expected []", result.Votes) + if options[0].Weight != 0 || options[1].Weight != 1 || options[2].Weight != 2 { + t.Errorf("Expected weights to be 0,1,2, got %d, %d, %d", options[0].Weight, options[1].Weight, options[2].Weight) } - if len(result.UserIDs) != 0 { - t.Errorf("Got userIDs %v, expected []", result.UserIDs) + o1, _ := options[0].MeetingUserID.Value() + o2, _ := options[1].MeetingUserID.Value() + o3, _ := options[2].MeetingUserID.Value() + if o1 != 31 || o2 != 11 || o3 != 21 { + t.Errorf("Expected meeting user ids to be 31,11,21, got %d, %d, %d", o1, o2, o3) } }) } -func TestVoteClear(t *testing.T) { - ctx := context.Background() - backend := memory.New() - v, _, _ := vote.New(ctx, backend, backend, &StubGetter{}, true) +func TestManually(t *testing.T) { + t.Parallel() - if err := v.Clear(ctx, 1); err != nil { - t.Fatalf("Clear returned unexpected error: %v", err) + if testing.Short() { + t.Skip("Postgres Test") } -} -func TestVoteClearAll(t *testing.T) { - ctx := context.Background() - backend := memory.New() - v, _, _ := vote.New(ctx, backend, backend, &StubGetter{}, true) + ctx := t.Context() - if err := v.ClearAll(ctx); err != nil { - t.Fatalf("ClearAll returned unexpected error: %v", err) + pg, err := pgtest.NewPostgresTest(ctx) + if err != nil { + t.Fatalf("Error starting postgres: %v", err) } -} + defer pg.Close() -func TestVoteVote(t *testing.T) { - ctx := context.Background() - backend := memory.New() - ds := &StubGetter{ - data: dsmock.YAMLData(` - poll/1: - meeting_id: 1 - entitled_group_ids: [1] - pollmethod: Y - global_yes: true - backend: fast - type: pseudoanonymous - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll - - - meeting/1/id: 1 - - user/1: - is_present_in_meeting_ids: [1] - meeting_user_ids: [10] - - meeting_user/10: - user_id: 1 - group_ids: [1] - meeting_id: 1 - `), - } - v, _, _ := vote.New(ctx, backend, backend, ds, true) + data := `--- + user/5: + username: admin + organization_management_level: superadmin - t.Run("Poll does not exist in DS", func(t *testing.T) { - err := v.Vote(ctx, 404, 1, strings.NewReader(`{"value":"Y"}`)) + motion/5: + meeting_id: 1 + sequential_number: 1 + title: my motion + state_id: 1 - if !errors.Is(err, vote.ErrNotExists) { - t.Errorf("Expected ErrNotExists, got: %v", err) - } - }) + list_of_speakers/7: + content_object_id: motion/5 + sequential_number: 1 + meeting_id: 1 + + meeting/1/welcome_title: hello world + ` + + withData(t, pg, data, func(service *vote.Vote, flow flow.Flow) { + t.Run("Create", func(t *testing.T) { + body := `{ + "title": "my poll", + "content_object_id": "motion/5", + "method": "approval", + "config": {}, + "visibility": "manually", + "meeting_id": 1, + "result": {"no":"23","yes":"42"} + }` + + id, err := service.Create(ctx, 5, strings.NewReader(body)) + if err != nil { + t.Fatalf("Error creating poll: %v", err) + } - t.Run("Unknown poll", func(t *testing.T) { - err := v.Vote(ctx, 1, 1, strings.NewReader(`{"value":"Y"}`)) + if id != 1 { + t.Errorf("Expected id 1, got %d", id) + } - if !errors.Is(err, vote.ErrNotExists) { - t.Errorf("Expected ErrNotExists, got: %v", err) - } - }) + poll, err := dsmodels.New(flow).Poll(1).First(ctx) + if err != nil { + t.Fatalf("Fetch poll: %v", err) + } - if err := backend.Start(ctx, 1); err != nil { - t.Fatalf("Starting poll returned unexpected error: %v", err) - } + if poll.State != "finished" { + t.Errorf("Poll is in state %s, expected state finished", poll.State) + } + + if poll.Result != `{"no":"23","yes":"42"}` { + t.Errorf("Result does not match") + } + }) - t.Run("Invalid json", func(t *testing.T) { - err := v.Vote(ctx, 1, 1, strings.NewReader(`{123`)) + t.Run("Reset", func(t *testing.T) { + err := service.Reset(ctx, 1, 5) + if err != nil { + t.Fatalf("Error creating poll: %v", err) + } - var errTyped vote.TypeError - if !errors.As(err, &errTyped) { - t.Fatalf("Vote() did not return an TypeError, got: %v", err) - } + poll, err := dsmodels.New(flow).Poll(1).First(ctx) + if err != nil { + t.Fatalf("Fetch poll: %v", err) + } - if errTyped != vote.ErrInvalid { - t.Errorf("Got error type `%s`, expected `%s`", errTyped.Type(), vote.ErrInvalid.Type()) - } + if poll.State != "finished" { + t.Errorf("State == %s. A manually poll has to be in state finished after a reset", poll.State) + } + }) }) +} - t.Run("Invalid format", func(t *testing.T) { - err := v.Vote(ctx, 1, 1, strings.NewReader(`{}`)) +func TestVote(t *testing.T) { + t.Parallel() - var errTyped vote.TypeError - if !errors.As(err, &errTyped) { - t.Fatalf("Vote() did not return an TypeError, got: %v", err) - } + if testing.Short() { + t.Skip("Postgres Test") + } - if errTyped != vote.ErrInvalid { - t.Errorf("Got error type `%s`, expected `%s`", errTyped.Type(), vote.ErrInvalid.Type()) - } - }) + ctx := t.Context() - t.Run("Valid data", func(t *testing.T) { - err := v.Vote(ctx, 1, 1, strings.NewReader(`{"value":"Y"}`)) - if err != nil { - t.Fatalf("Vote returned unexpected error: %v", err) - } - }) + pg, err := pgtest.NewPostgresTest(ctx) + if err != nil { + t.Fatalf("Error starting postgres: %v", err) + } + defer pg.Close() - t.Run("User has voted", func(t *testing.T) { - err := v.Vote(ctx, 1, 1, strings.NewReader(`{"value":"Y"}`)) - if err == nil { - t.Fatalf("Vote returned no error") - } + data := `--- + motion/5: + meeting_id: 1 + sequential_number: 1 + title: my motion + state_id: 1 - var errTyped vote.TypeError - if !errors.As(err, &errTyped) { - t.Fatalf("Vote() did not return an TypeError, got: %v", err) - } + list_of_speakers/7: + content_object_id: motion/5 + sequential_number: 1 + meeting_id: 1 - if errTyped != vote.ErrDoubleVote { - t.Errorf("Got error type `%s`, expected `%s`", errTyped.Type(), vote.ErrDoubleVote.Type()) - } - }) + meeting/1: + present_user_ids: [30] - t.Run("Poll is stopped", func(t *testing.T) { - backend.Stop(ctx, 1) + user/30: + username: tom + meeting_user/300: + group_ids: [40] + user_id: 30 + meeting_id: 1 - err := v.Vote(ctx, 1, 1, strings.NewReader(`{"value":"Y"}`)) - if err == nil { - t.Fatalf("Vote returned no error") - } + group/40: + name: delegate + meeting_id: 1 - var errTyped vote.TypeError - if !errors.As(err, &errTyped) { - t.Fatalf("Vote() did not return an TypeError, got: %v", err) - } + poll_config_approval/77: + poll_id: 5 + allow_abstain: true - if errTyped != vote.ErrStopped { - t.Errorf("Got error type `%s`, expected `%s`", errTyped.Type(), vote.ErrStopped.Type()) - } - }) -} + poll/5: + title: my poll + config_id: poll_config_approval/77 + visibility: open + sequential_number: 1 + content_object_id: motion/5 + meeting_id: 1 + state: started + entitled_group_ids: [40] + ` + + withData( + t, + pg, + data, + func(service *vote.Vote, flow flow.Flow) { + t.Run("Simple Vote", func(t *testing.T) { + defer pg.Cleanup(t) + + body := `{"value":"Yes"}` + if err := service.Vote(ctx, 5, 30, strings.NewReader(body)); err != nil { + t.Fatalf("Error processing poll: %v", err) + } + + ds := dsmodels.New(flow) + vote, err := ds.Ballot(1).First(t.Context()) + if err != nil { + t.Fatalf("Error: Getting vote: %v", err) + } + + if id, _ := vote.ActingMeetingUserID.Value(); id != 300 { + t.Errorf("Expected acting_meeting_user ID to be 300, got %d", id) + } -func TestVoteNoRequests(t *testing.T) { - // This tests makes sure, that a request to vote does not do any reading - // from the database. All values have to be in the cache from pollpreload. + if vote.Value != `"Yes"` { + t.Errorf("Expected vote value to be 'Yes', got '%s'", vote.Value) + } + }) + }, + ) +} +func TestVoteWeight(t *testing.T) { for _, tt := range []struct { - name string - data string - vote string - expectVotedUserID int + name string + data string + + expectWeight string }{ { - "normal vote", - `--- + "No weight", + ` poll/1: - meeting_id: 50 - entitled_group_ids: [5] - pollmethod: Y - global_yes: true - state: started - backend: fast - type: pseudoanonymous + meeting_id: 1 + entitled_group_ids: [1] + config_id: poll_config_approval/77 + visibility: open content_object_id: some_field/1 sequential_number: 1 - onehundred_percent_base: base title: myPoll - meeting/50/users_enable_vote_delegations: true + poll_config_approval/77: + poll_id: 5 + allow_abstain: true + + meeting/1/id: 1 user/1: - is_present_in_meeting_ids: [50] + is_present_in_meeting_ids: [1] meeting_user_ids: [10] meeting_user/10: - meeting_id: 50 - group_ids: [5] user_id: 1 - - group/5/meeting_user_ids: [10] + group_ids: [1] + meeting_id: 1 `, - `{"value":"Y"}`, - 1, + "1", }, { - "delegation vote", - `--- + "Weight enabled, user has no weight", + ` poll/1: - meeting_id: 50 - entitled_group_ids: [5] - pollmethod: Y - global_yes: true - state: started - backend: fast - type: pseudoanonymous + meeting_id: 1 + entitled_group_ids: [1] + config_id: poll_config_approval/77 + visibility: open content_object_id: some_field/1 sequential_number: 1 - onehundred_percent_base: base title: myPoll - meeting/50/users_enable_vote_delegations: true + poll_config_approval/77: + poll_id: 5 + allow_abstain: true - user: - 1: - is_present_in_meeting_ids: [50] - meeting_user_ids: [10] - 2: - meeting_user_ids: [20] + meeting/1/users_enable_vote_weight: true - meeting_user: - 10: - user_id: 1 - vote_delegations_from_ids: [20] - meeting_id: 50 - 20: - meeting_id: 50 - vote_delegated_to_id: 10 - group_ids: [5] - user_id: 2 - - group/5/meeting_user_ids: [20] + user/1: + is_present_in_meeting_ids: [1] + meeting_user_ids: [10] + meeting_user/10: + user_id: 1 + group_ids: [1] + meeting_id: 1 `, - `{"user_id":2,"value":"Y"}`, - 2, + "1", }, { - "vote weight enabled", - `--- + "Weight enabled, user has default weight", + ` poll/1: - meeting_id: 50 - entitled_group_ids: [5] - pollmethod: Y - global_yes: true - state: started - backend: fast - type: pseudoanonymous + meeting_id: 1 + entitled_group_ids: [1] + config_id: poll_config_approval/77 + visibility: open content_object_id: some_field/1 sequential_number: 1 - onehundred_percent_base: base title: myPoll - meeting/50: - users_enable_vote_weight: true - users_enable_vote_delegations: true + poll_config_approval/77: + poll_id: 5 + allow_abstain: true + + meeting/1/users_enable_vote_weight: true user/1: - is_present_in_meeting_ids: [50] + is_present_in_meeting_ids: [1] meeting_user_ids: [10] - - meeting_user: - 10: - group_ids: [5] - user_id: 1 - meeting_id: 50 - - group/5/meeting_user_ids: [10] + default_vote_weight: "2.000000" + meeting_user/10: + user_id: 1 + group_ids: [1] + meeting_id: 1 `, - `{"value":"Y"}`, - 1, + "2", }, { - "vote weight enabled and delegated", - `--- + "Weight enabled, user has default weight and meeting weight", + ` poll/1: - meeting_id: 50 - entitled_group_ids: [5] - pollmethod: Y - global_yes: true - state: started - backend: fast - type: pseudoanonymous + meeting_id: 1 + entitled_group_ids: [1] + config_id: poll_config_approval/77 + visibility: open content_object_id: some_field/1 sequential_number: 1 - onehundred_percent_base: base title: myPoll - meeting/50: - users_enable_vote_weight: true - users_enable_vote_delegations: true - - user: - 1: - is_present_in_meeting_ids: [50] - meeting_user_ids: [10] - 2: - meeting_user_ids: [20] - - meeting_user: - 10: - meeting_id: 50 - user_id: 1 - - 20: - group_ids: [5] - meeting_id: 50 - user_id: 2 - vote_delegated_to_id: 10 + poll_config_approval/77: + poll_id: 5 + allow_abstain: true - group/5/meeting_user_ids: [20] - `, - `{"user_id":2,"value":"Y"}`, - 2, - }, - } { - t.Run(tt.name, func(t *testing.T) { - ctx := context.Background() - ds := dsmock.NewFlow( - dsmock.YAMLData(tt.data), - dsmock.NewCounter, - ) - counter := ds.Middlewares()[0].(*dsmock.Counter) - cachedDS := cache.New(ds) - backend := memory.New() - v, _, _ := vote.New(ctx, backend, backend, cachedDS, true) - - if err := v.Start(ctx, 1); err != nil { - t.Fatalf("Can not start poll: %v", err) - } - - counter.Reset() - - if err := v.Vote(ctx, 1, 1, strings.NewReader(tt.vote)); err != nil { - t.Errorf("Vote returned unexpected error: %v", err) - } - - if counter.Count() != 0 { - t.Errorf("Vote send %d requests to the datastore: %v", counter.Count(), counter.Requests()) - } - - backend.AssertUserHasVoted(t, 1, tt.expectVotedUserID) - }) - } -} - -func TestVoteDelegationAndGroup(t *testing.T) { - for _, tt := range []struct { - name string - data string - vote string - - expectVotedUserID int - }{ - { - "Not delegated", - ` - poll/1: - meeting_id: 1 - entitled_group_ids: [1] - pollmethod: Y - global_yes: true - backend: fast - type: pseudoanonymous - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll - - meeting/1/users_enable_vote_delegations: true + meeting/1/users_enable_vote_weight: true user/1: is_present_in_meeting_ids: [1] meeting_user_ids: [10] - + default_vote_weight: "2.000000" meeting_user/10: + user_id: 1 group_ids: [1] meeting_id: 1 + vote_weight: "3.000000" `, - `{"value":"Y"}`, - - 1, + "3", }, - { - "Not delegated not present", + "Weight enabled, user has default weight and meeting weight in other meeting", ` poll/1: meeting_id: 1 entitled_group_ids: [1] - pollmethod: Y - global_yes: true - backend: fast - type: pseudoanonymous + config_id: poll_config_approval/77 + visibility: open content_object_id: some_field/1 sequential_number: 1 - onehundred_percent_base: base title: myPoll - meeting/1/users_enable_vote_delegations: true - - user/1: - meeting_user_ids: [10] - - meeting_user/10: - group_ids: [1] - meeting_id: 1 - `, - `{"value":"Y"}`, - - 0, - }, + poll_config_approval/77: + poll_id: 5 + allow_abstain: true - { - "Not delegated not in group", - ` - poll/1: - meeting_id: 1 - entitled_group_ids: [1] - pollmethod: Y - global_yes: true - backend: fast - type: pseudoanonymous - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll - - meeting/1/users_enable_vote_delegations: true + meeting/1/users_enable_vote_weight: true user/1: is_present_in_meeting_ids: [1] - meeting_user_ids: [10] - + meeting_user_ids: [10,11] + default_vote_weight: "2.000000" meeting_user/10: - group_ids: [] + user_id: 1 + group_ids: [1] meeting_id: 1 + meeting_user/11: + user_id: 1 + group_ids: [1] + meeting_id: 2 + vote_weight: "3.000000" `, - `{"value":"Y"}`, - - 0, + "2", }, + } { + t.Run(tt.name, func(t *testing.T) { + ds := dsfetch.New(dsmock.Stub(dsmock.YAMLData(tt.data))) + weight, err := vote.CalcVoteWeight(t.Context(), ds, 10) + if err != nil { + t.Fatalf("CalcVote: %v", err) + } - { - "Vote for self", - ` - poll/1: - meeting_id: 1 - entitled_group_ids: [1] - pollmethod: Y - global_yes: true - backend: fast - type: pseudoanonymous - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll + if weight.String() != tt.expectWeight { + t.Errorf("got weight %q, expected %q", weight, tt.expectWeight) + } + }) + } +} - meeting/1/users_enable_vote_delegations: true +func TestVoteStart(t *testing.T) { + t.Parallel() - user/1: - is_present_in_meeting_ids: [1] - meeting_user_ids: [10] + if testing.Short() { + t.Skip("Postgres Test") + } - meeting_user/10: - group_ids: [1] - meeting_id: 1 - `, - `{"user_id": 1, "value":"Y"}`, + ctx := t.Context() - 1, - }, + pg, err := pgtest.NewPostgresTest(ctx) + if err != nil { + t.Fatalf("Error starting postgres: %v", err) + } + defer pg.Close() - { - "Vote for self not activated", - ` - poll/1: - meeting_id: 1 - entitled_group_ids: [1] - pollmethod: Y - global_yes: true - backend: fast - type: pseudoanonymous - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll + data := `--- + motion/5: + meeting_id: 1 + sequential_number: 1 + title: my motion + state_id: 1 - meeting/1/users_enable_vote_delegations: false + list_of_speakers/7: + content_object_id: motion/5 + sequential_number: 1 + meeting_id: 1 - user/1: - is_present_in_meeting_ids: [1] - meeting_user_ids: [10] + meeting/1: + present_user_ids: [30] + + user: + 30: + username: tom + 5: + username: admin + organization_management_level: superadmin + + meeting_user/300: + group_ids: [40] + user_id: 30 + meeting_id: 1 - meeting_user/10: - group_ids: [1] - meeting_id: 1 - `, - `{"user_id": 1, "value":"Y"}`, + group/40: + name: delegate + meeting_id: 1 - 1, - }, + poll/5: + title: normal poll + config_id: poll_config_approval/77 + visibility: open + sequential_number: 1 + content_object_id: motion/5 + meeting_id: 1 + state: created + entitled_group_ids: [40] + + + poll_config_approval/77: + poll_id: 5 + allow_abstain: true + ` + + withData( + t, + pg, + data, + func(service *vote.Vote, flow flow.Flow) { + t.Run("Unknown poll", func(t *testing.T) { + err := service.Start(ctx, 404, 5) + if !errors.Is(err, vote.ErrNotExists) { + t.Errorf("Start returned unexpected error: %v", err) + } + }) - { - "Vote for anonymous", - ` - poll/1: - meeting_id: 1 - entitled_group_ids: [1] - pollmethod: Y - global_yes: true - backend: fast - type: pseudoanonymous - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll + t.Run("Not started poll", func(t *testing.T) { + if err := service.Start(ctx, 5, 5); err != nil { + t.Errorf("Start returned unexpected error: %v", err) + } + }) - meeting/1/users_enable_vote_delegations: true + t.Run("Start poll a second time", func(t *testing.T) { + if err := service.Start(ctx, 5, 5); err != nil { + t.Errorf("Start returned unexpected error: %v", err) + } + }) - user/1: - is_present_in_meeting_ids: [1] - meeting_user_ids: [10] + t.Run("Start a finished poll", func(t *testing.T) { + if err := service.Start(ctx, 5, 5); err != nil { + t.Fatalf("Start returned unexpected error: %v", err) + } - meeting_user/10: - group_ids: [1] - meeting_id: 1 - `, - `{"user_id": 0, "value":"Y"}`, + if err := service.Finalize(ctx, 5, 5, false, false); err != nil { + t.Fatalf("finish poll: %v", err) + } - 0, + err := service.Start(ctx, 5, 5) + if !errors.Is(err, vote.ErrInvalid) { + t.Errorf("Start returned unexpected error: %v", err) + } + }) }, + ) +} - { - "Vote for other without delegation", - ` - poll/1: - meeting_id: 1 - entitled_group_ids: [1] - pollmethod: Y - global_yes: true - backend: fast - type: pseudoanonymous - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll - - meeting/1/users_enable_vote_delegations: true +func TestVoteFinalize(t *testing.T) { + t.Parallel() - user/1/is_present_in_meeting_ids: [1] - user/2/meeting_user_ids: [20] + if testing.Short() { + t.Skip("Postgres Test") + } - user: - 1: - is_present_in_meeting_ids: [1] - meeting_user_ids: [10] + ctx := t.Context() - 2: - meeting_user_ids: [20] + pg, err := pgtest.NewPostgresTest(ctx) + if err != nil { + t.Fatalf("Error starting postgres: %v", err) + } + defer pg.Close() - meeting_user: - 10: - meeting_id: 1 - user_id: 1 - 20: - group_ids: [1] - meeting_id: 1 - `, - `{"user_id": 2, "value":"Y"}`, + data := `--- + motion/5: + meeting_id: 1 + sequential_number: 1 + title: my motion + state_id: 1 - 0, - }, + list_of_speakers/7: + content_object_id: motion/5 + sequential_number: 1 + meeting_id: 1 - { - "Vote for other with delegation", - ` - poll/1: - meeting_id: 1 - entitled_group_ids: [1] - pollmethod: Y - global_yes: true - backend: fast - type: pseudoanonymous - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll + meeting/1: + present_user_ids: [30] + + user: + 30: + username: tom + 5: + username: admin + organization_management_level: superadmin + + meeting_user/300: + group_ids: [40] + user_id: 30 + meeting_id: 1 - meeting/1/users_enable_vote_delegations: true + meeting_user/500: + group_ids: [40] + user_id: 5 + meeting_id: 1 - user: - 1: - is_present_in_meeting_ids: [1] - meeting_user_ids: [10] + group/40: + name: delegate + meeting_id: 1 - 2: - meeting_user_ids: [20] + poll/5: + title: poll with votes + config_id: poll_config_approval/77 + visibility: open + sequential_number: 1 + content_object_id: motion/5 + meeting_id: 1 + state: started + entitled_group_ids: [40] + + poll_config_approval/77: + poll_id: 5 + allow_abstain: true + + ballot/1: + poll_id: 5 + value: '"yes"' + represented_meeting_user_id: 300 + ballot/2: + poll_id: 5 + value: '"no"' + represented_meeting_user_id: 500 + ` + + withData( + t, + pg, + data, + func(service *vote.Vote, flow flow.Flow) { + t.Run("Unknown poll", func(t *testing.T) { + err := service.Finalize(ctx, 404, 5, false, false) + if !errors.Is(err, vote.ErrNotExists) { + t.Errorf("Stopping an unknown poll has to return an ErrNotExists, got: %v", err) + } + }) - meeting_user: - 10: - meeting_id: 1 - user_id: 1 - - 20: - group_ids: [1] - meeting_id: 1 - vote_delegated_to_id: 10 - `, - `{"user_id": 2, "value":"Y"}`, + t.Run("Poll with votes", func(t *testing.T) { + if err := service.Finalize(ctx, 5, 5, false, false); err != nil { + t.Fatalf("Stop returned unexpected error: %v", err) + } - 2, - }, + poll, err := dsmodels.New(flow).Poll(5).First(ctx) + if err != nil { + t.Fatalf("load poll after finalize: %v", err) + } - { - "Vote for other with delegation not activated", - ` - poll/1: - meeting_id: 1 - entitled_group_ids: [1] - pollmethod: Y - global_yes: true - backend: fast - type: pseudoanonymous - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll + if poll.Result != `{"no":"1","yes":"1"}` { + t.Errorf("Got result %s, expected %s", poll.Result, `{"no":"1","yes":"1"}`) + } - meeting/1/users_enable_vote_delegations: false + if poll.State != "finished" { + t.Errorf("Poll state is %s, expected finished", poll.State) + } - user: - 1: - is_present_in_meeting_ids: [1] - meeting_user_ids: [10] + if slices.Compare(poll.VotedIDs, []int{30, 5}) == 0 { + t.Errorf("VotedIDs are %v, expected %v", poll.VotedIDs, []int{30, 5}) + } + }) - 2: - meeting_user_ids: [20] + t.Run("finish poll a second time", func(t *testing.T) { + if err := service.Finalize(ctx, 5, 5, false, false); err != nil { + t.Fatalf("Stop returned unexpected error: %v", err) + } - meeting_user: - 10: - meeting_id: 1 - user_id: 1 - - 20: - group_ids: [1] - meeting_id: 1 - vote_delegated_to_id: 10 - `, - `{"user_id": 2, "value":"Y"}`, + poll, err := dsmodels.New(flow).Poll(5).First(ctx) + if err != nil { + t.Fatalf("load poll after finalize: %v", err) + } - 0, + if poll.State != "finished" { + t.Errorf("Poll state is %s, expected finished", poll.State) + } + }) }, + ) +} - { - "Vote for other with delegation not in group", - ` - poll/1: - meeting_id: 1 - entitled_group_ids: [1] - pollmethod: Y - global_yes: true - backend: fast - type: pseudoanonymous - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll - - meeting/1/users_enable_vote_delegations: true - - user: - 1: - is_present_in_meeting_ids: [1] - meeting_user_ids: [10] +func TestVoteVote(t *testing.T) { + t.Parallel() - 2: - meeting_user_ids: [20] + if testing.Short() { + t.Skip("Postgres Test") + } - meeting_user: - 10: - meeting_id: 1 - user_id: 1 - - 20: - group_ids: [] - meeting_id: 1 - vote_delegated_to_id: 10 - `, - `{"user_id": 2, "value":"Y"}`, + ctx := t.Context() - 0, - }, - - { - "Vote for other with self not in group", - ` - poll/1: - meeting_id: 1 - entitled_group_ids: [1] - pollmethod: Y - global_yes: true - backend: fast - type: pseudoanonymous - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll + pg, err := pgtest.NewPostgresTest(ctx) + if err != nil { + t.Fatalf("Error starting postgres: %v", err) + } + defer pg.Close() - meeting/1/users_enable_vote_delegations: true + data := `--- + motion/5: + meeting_id: 1 + sequential_number: 1 + title: my motion + state_id: 1 - user: - 1: - is_present_in_meeting_ids: [1] - meeting_user_ids: [10] + list_of_speakers/7: + content_object_id: motion/5 + sequential_number: 1 + meeting_id: 1 - 2: - meeting_user_ids: [20] + meeting/1: + present_user_ids: [30] + + user: + 30: + username: tom + 5: + username: admin + organization_management_level: superadmin + + meeting_user/300: + group_ids: [40] + user_id: 30 + meeting_id: 1 - meeting_user: - 10: - meeting_id: 1 - user_id: 1 - - 20: - group_ids: [1] - meeting_id: 1 - vote_delegated_to_id: 10 - `, - `{"user_id": 2, "value":"Y"}`, + group/40: + name: delegate + meeting_id: 1 - 2, - }, + poll/5: + title: poll with votes + config_id: poll_config_approval/77 + visibility: open + sequential_number: 1 + content_object_id: motion/5 + meeting_id: 1 + state: started + entitled_group_ids: [40] + + poll_config_approval/77: + poll_id: 5 + allow_abstain: true + ` + + withData( + t, + pg, + data, + func(service *vote.Vote, flow flow.Flow) { + t.Run("Poll does not exist in DS", func(t *testing.T) { + err := service.Vote(ctx, 404, 1, strings.NewReader(`{"value":"Y"}`)) + if !errors.Is(err, vote.ErrNotExists) { + t.Errorf("Expected ErrNotExists, got: %v", err) + } + }) - { - "Vote for self when delegation is activated users_forbid_delegator_to_vote==false", - ` - poll/1: - meeting_id: 1 - entitled_group_ids: [1] - pollmethod: Y - global_yes: true - backend: fast - type: pseudoanonymous - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll + t.Run("Invalid json", func(t *testing.T) { + err := service.Vote(ctx, 5, 30, strings.NewReader(`{123`)) - meeting/1: - users_enable_vote_delegations: true - users_forbid_delegator_to_vote: false + var errTyped vote.TypeError + if !errors.As(err, &errTyped) { + t.Fatalf("Vote() did not return an TypeError, got: %v", err) + } - user: - 1: - is_present_in_meeting_ids: [1] - meeting_user_ids: [10] + if errTyped != vote.ErrInvalid { + t.Errorf("Got error type `%s`, expected `%s`", errTyped.Type(), vote.ErrInvalid.Type()) + } + }) - 2: - meeting_user_ids: [20] + t.Run("Invalid format", func(t *testing.T) { + err := service.Vote(ctx, 5, 30, strings.NewReader(`{}`)) - meeting_user: - 10: - meeting_id: 1 - user_id: 1 - group_ids: [1] - vote_delegated_to_id: 20 + var errTyped vote.TypeError + if !errors.As(err, &errTyped) { + t.Fatalf("Vote() did not return an TypeError, got: %v", err) + } - 20: - meeting_id: 1 + if errTyped != vote.ErrInvalid { + t.Errorf("Got error type `%s`, expected `%s`", errTyped.Type(), vote.ErrInvalid.Type()) + } + }) - `, - `{"user_id": 1, "value":"Y"}`, + t.Run("Valid data", func(t *testing.T) { + err := service.Vote(ctx, 5, 30, strings.NewReader(`{"value":"Yes"}`)) + if err != nil { + t.Fatalf("Vote returned unexpected error: %v", err) + } + }) - 1, - }, + t.Run("User has voted", func(t *testing.T) { + err := service.Vote(ctx, 5, 30, strings.NewReader(`{"value":"Yes"}`)) + if err == nil { + t.Fatalf("Vote returned no error") + } - { - "Vote for self when delegation is activated users_forbid_delegator_to_vote==true", - ` - poll/1: - meeting_id: 1 - entitled_group_ids: [1] - pollmethod: Y - global_yes: true - backend: fast - type: pseudoanonymous - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll + var errTyped vote.TypeError + if !errors.As(err, &errTyped) { + t.Fatalf("Vote() did not return an TypeError, got: %v", err) + } - meeting/1: - users_enable_vote_delegations: true - users_forbid_delegator_to_vote: true + if errTyped != vote.ErrDoubleVote { + t.Errorf("Got error type `%s`, expected `%s`", errTyped.Type(), vote.ErrDoubleVote.Type()) + } + }) - user: - 1: - is_present_in_meeting_ids: [1] - meeting_user_ids: [10] + t.Run("Poll is stopped", func(t *testing.T) { + if err := service.Finalize(ctx, 5, 5, false, false); err != nil { + t.Fatalf("Finalize poll: %v", err) + } - 2: - meeting_user_ids: [20] + err := service.Vote(ctx, 5, 30, strings.NewReader(`{"value":"Yes"}`)) + if err == nil { + t.Fatalf("Vote returned no error") + } - meeting_user: - 10: - meeting_id: 1 - user_id: 1 - group_ids: [1] - vote_delegated_to_id: 20 - - 20: - meeting_id: 1 - `, - `{"user_id": 1, "value":"Y"}`, + var errTyped vote.TypeError + if !errors.As(err, &errTyped) { + t.Fatalf("Vote() did not return an TypeError, got: %v", err) + } - 0, + if errTyped != vote.ErrNotStarted { + t.Errorf("Got error type `%s`, expected `%s`", errTyped.Type(), vote.ErrNotStarted.Type()) + } + }) }, + ) +} - { - "Vote for self when delegation is deactivated users_forbid_delegator_to_vote==true", - ` - poll/1: - meeting_id: 1 - entitled_group_ids: [1] - pollmethod: Y - global_yes: true - backend: fast - type: pseudoanonymous - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll +func TestVoteDelegationAndGroup(t *testing.T) { + t.Parallel() - meeting/1: - users_enable_vote_delegations: false - users_forbid_delegator_to_vote: true + if testing.Short() { + t.Skip("Postgres Test") + } - user: - 1: - is_present_in_meeting_ids: [1] - meeting_user_ids: [10] + ctx := t.Context() - 2: - meeting_user_ids: [20] + pg, err := pgtest.NewPostgresTest(ctx) + if err != nil { + t.Fatalf("Error starting postgres: %v", err) + } + defer pg.Close() - meeting_user: - 10: - meeting_id: 1 - user_id: 1 - group_ids: [1] - vote_delegated_to_id: 20 + baseData := ` + meeting/1/users_enable_vote_delegations: true - 20: - meeting_id: 1 + motion/5: + meeting_id: 1 + sequential_number: 1 + title: my motion + state_id: 1 - `, - `{"user_id": 1, "value":"Y"}`, + list_of_speakers/7: + content_object_id: motion/5 + sequential_number: 1 + meeting_id: 1 - 1, - }, - } { - t.Run(tt.name, func(t *testing.T) { - ctx := context.Background() - backend := memory.New() - ds := &StubGetter{data: dsmock.YAMLData(tt.data)} + group/40: + name: delegates + meeting_id: 1 - v, _, _ := vote.New(ctx, backend, backend, ds, true) + user: + 5: + username: admin + organization_management_level: superadmin + 30: + username: tom - if err := backend.Start(ctx, 1); err != nil { - t.Fatalf("backend.Start(): %v", err) - } + 40: + username: georg + + meeting_user: + 31: + user_id: 30 + meeting_id: 1 - err := v.Vote(ctx, 1, 1, strings.NewReader(tt.vote)) + 41: + user_id: 40 + meeting_id: 1 - if tt.expectVotedUserID != 0 { - if err != nil { - t.Fatalf("Vote returned unexpected error: %v", err) - } + poll_config_approval/77: + poll_id: 5 + allow_abstain: true - backend.AssertUserHasVoted(t, 1, tt.expectVotedUserID) - return - } + poll/5: + title: normal poll + config_id: poll_config_approval/77 + visibility: open + sequential_number: 1 + content_object_id: motion/5 + meeting_id: 1 + state: started + entitled_group_ids: [40] - if !errors.Is(err, vote.ErrNotAllowed) { - t.Fatalf("Expected NotAllowedError, got: %v", err) - } - }) - } -} + ` -func TestVoteWeight(t *testing.T) { for _, tt := range []struct { name string data string + vote string - expectWeight string + expectRepresentedMeetingUserID int }{ { - "No weight", + "Not delegated", ` - poll/1: - meeting_id: 1 - entitled_group_ids: [1] - pollmethod: Y - global_yes: true - backend: fast - type: pseudoanonymous - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll - - meeting/1/id: 1 - - user/1: + user/30: is_present_in_meeting_ids: [1] - meeting_user_ids: [10] - meeting_user/10: - group_ids: [1] - meeting_id: 1 + + meeting_user/31: + group_ids: [40] `, - "1.000000", + `{"value":"Yes"}`, + + 31, }, + { - "Weight enabled, user has no weight", + "Not delegated not present", ` - poll/1: - meeting_id: 1 - entitled_group_ids: [1] - pollmethod: Y - global_yes: true - backend: fast - type: pseudoanonymous - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll + meeting_user/31: + group_ids: [40] + `, + `{"value":"Yes"}`, - meeting/1/users_enable_vote_weight: true + 0, + }, - user/1: + { + "Not delegated not in group", + ` + user/30: is_present_in_meeting_ids: [1] - meeting_user_ids: [10] - meeting_user/10: - group_ids: [1] - meeting_id: 1 `, - "1.000000", + `{"value":"Yes"}`, + + 0, }, + { - "Weight enabled, user has default weight", + "Vote for self", ` - poll/1: - meeting_id: 1 - entitled_group_ids: [1] - pollmethod: Y - global_yes: true - backend: fast - type: pseudoanonymous - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll - - meeting/1/users_enable_vote_weight: true - - user/1: + user/30: is_present_in_meeting_ids: [1] - meeting_user_ids: [10] - default_vote_weight: "2.000000" - meeting_user/10: - group_ids: [1] - meeting_id: 1 + + meeting_user/31: + group_ids: [40] `, - "2.000000", + `{"meeting_user_id": 31, "value":"Yes"}`, + + 31, }, + { - "Weight enabled, user has default weight and meeting weight", + "Vote for self not activated", ` - poll/1: - meeting_id: 1 - entitled_group_ids: [1] - pollmethod: Y - global_yes: true - backend: fast - type: pseudoanonymous - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll - - meeting/1/users_enable_vote_weight: true - - user/1: + meeting/1/users_enable_vote_delegations: false + user/30: is_present_in_meeting_ids: [1] - meeting_user_ids: [10] - default_vote_weight: "2.000000" - meeting_user/10: - group_ids: [1] - meeting_id: 1 - vote_weight: "3.000000" + + meeting_user/31: + group_ids: [40] `, - "3.000000", + `{"meeting_user_id": 31, "value":"Yes"}`, + + 31, }, + { - "Weight enabled, user has default weight and meeting weight in other meeting", + "Vote for anonymous", ` - poll/1: - meeting_id: 1 - entitled_group_ids: [1] - pollmethod: Y - global_yes: true - backend: fast - type: pseudoanonymous - content_object_id: some_field/1 - sequential_number: 1 - onehundred_percent_base: base - title: myPoll - - meeting/1/users_enable_vote_weight: true - - user/1: + user/30: is_present_in_meeting_ids: [1] - meeting_user_ids: [10,11] - default_vote_weight: "2.000000" - meeting_user/10: - group_ids: [1] - meeting_id: 1 - meeting_user/11: - group_ids: [1] - meeting_id: 2 - vote_weight: "3.000000" + + meeting_user/31: + group_ids: [40] `, - "2.000000", + `{"meeting_user_id": 0, "value":"Yes"}`, + + 0, }, - } { - t.Run(tt.name, func(t *testing.T) { - ctx := context.Background() - backend := memory.New() - ds := &StubGetter{data: dsmock.YAMLData(tt.data)} - v, _, _ := vote.New(ctx, backend, backend, ds, true) - if err := backend.Start(ctx, 1); err != nil { - t.Fatalf("bakckend.Start: %v", err) - } + { + "Vote for other without delegation", + ` + user/30: + is_present_in_meeting_ids: [1] - if err := v.Vote(ctx, 1, 1, strings.NewReader(`{"value":"Y"}`)); err != nil { - t.Fatalf("vote returned unexpected error: %v", err) - } + meeting_user/31: + group_ids: [40] + `, + `{"meeting_user_id": 41, "value":"Yes"}`, - data, _, _ := backend.Stop(ctx, 1) + 0, + }, - if len(data) != 1 { - t.Fatalf("got %d vote objects, expected one", len(data)) - } + { + "Vote for other with delegation", + ` + user/30: + is_present_in_meeting_ids: [1] - var decoded struct { - Weight string `json:"weight"` - } - if err := json.Unmarshal(data[0], &decoded); err != nil { - t.Fatalf("decoding voteobject returned unexpected error: %v", err) - } + meeting_user: + 41: + group_ids: [40] + vote_delegated_to_id: 31 + `, + `{"meeting_user_id": 41, "value":"Yes"}`, - if decoded.Weight != tt.expectWeight { - t.Errorf("got weight %q, expected %q", decoded.Weight, tt.expectWeight) - } - }) - } -} + 41, + }, -func TestItLikeBackend(t *testing.T) { - ctx := context.Background() - backend := memory.New() + { + "Vote for other with delegation not activated", + ` + meeting/1/users_enable_vote_delegations: false + user/30: + is_present_in_meeting_ids: [1] - ds := dsmock.NewFlow(dsmock.YAMLData(`--- - organization/1/enable_electronic_voting: true - meeting/1: - name: my meeting - poll_couple_countdown: true - poll_countdown_id: 11 - is_active_in_organization_id: 1 - group_ids: [1] - meeting_user_ids: [11] - - projector_countdown/11: - default_time: 60 - running: false - countdown_time: 60 - meeting_id: 1 + meeting_user: + 41: + group_ids: [40] + vote_delegated_to_id: 31 + `, + `{"meeting_user_id": 41, "value":"Yes"}`, - group/1/meeting_user_ids: [11] + 0, + }, - option: - 1: - meeting_id: 1 - poll_id: 1 - 2: - meeting_id: 1 - poll_id: 1 + { + "Vote for other with delegation not in group", + ` + user/30: + is_present_in_meeting_ids: [1] - user/1: - is_present_in_meeting_ids: [1] - meeting_user_ids: [11] + meeting_user: + 41: + vote_delegated_to_id: 31 + `, + `{"meeting_user_id": 41, "value":"Yes"}`, - meeting_user/11: - meeting_id: 1 - user_id: 1 - group_ids: [1] + 0, + }, - assignment/1: - title: test_assignment_tcLT59bmXrXif424Qw7K - open_posts: 1 - meeting_id: 1 + { + "Vote for self when delegation is activated users_forbid_delegator_to_vote==false", + ` + meeting/1/users_forbid_delegator_to_vote: false + user/30: + is_present_in_meeting_ids: [1] - poll/1: - content_object_id: assignment/1 - title: test_title_04k0y4TwPLpJKaSvIGm1 - state: started - meeting_id: 1 - option_ids: [1, 2] - entitled_group_ids: [1] - votesinvalid: "0.000000" - votesvalid: "0.000000" - votescast: "0.000000" - backend: fast - pollmethod: YNA - type: named - sequential_number: 1 - onehundred_percent_base: base - `)) + meeting_user/31: + group_ids: [40] + vote_delegated_to_id: 41 + `, + `{"meeting_user_id": 31, "value":"Yes"}`, - v, _, _ := vote.New(ctx, backend, backend, ds, true) - if err := backend.Start(ctx, 1); err != nil { - t.Fatalf("bakckend.Start: %v", err) - } + 31, + }, - if err := v.Vote(ctx, 1, 1, strings.NewReader(`{"value": {"1": "Y"}}`)); err != nil { - t.Fatalf("vote returned unexpected error: %v", err) - } + { + "Vote for self when delegation is activated users_forbid_delegator_to_vote==true", + ` + meeting/1/users_forbid_delegator_to_vote: true + user/30: + is_present_in_meeting_ids: [1] - backend.AssertUserHasVoted(t, 1, 1) -} + meeting_user/31: + group_ids: [40] + vote_delegated_to_id: 41 + `, + `{"meeting_user_id": 31, "value":"Yes"}`, -func TestVotedPolls(t *testing.T) { - ctx := context.Background() + 0, + }, - backend := memory.New() - ds := dsmock.NewFlow(dsmock.YAMLData(`--- - poll/1: - backend: memory - meeting_id: 1 - type: pseudoanonymous - pollmethod: Y + { + "Vote for self when delegation is deactivated users_forbid_delegator_to_vote==true", + ` + meeting/1: + users_forbid_delegator_to_vote: true + users_enable_vote_delegations: false - user/5/id: 5 - `)) + user/30: + is_present_in_meeting_ids: [1] - backend.Start(ctx, 1) - backend.Vote(ctx, 1, 5, []byte(`"Y"`)) + meeting_user/31: + group_ids: [40] + vote_delegated_to_id: 41 + `, + `{"meeting_user_id": 31, "value":"Yes"}`, - v, _, _ := vote.New(ctx, backend, backend, ds, true) + 31, + }, + } { + t.Run(tt.name, func(t *testing.T) { + pg.Cleanup(t) - got, err := v.Voted(ctx, []int{1, 2}, 5) - if err != nil { - t.Fatalf("VotedPolls() returned unexected error: %v", err) - } + if err := pg.AddData(ctx, baseData); err != nil { + t.Fatalf("Insert base data: %v", err) + } - expect := map[int][]int{1: {5}, 2: nil} - if !reflect.DeepEqual(got, expect) { - t.Errorf("Voted() == `%v`, expected `%v`", got, expect) + withData( + t, + pg, + tt.data, + func(service *vote.Vote, flow flow.Flow) { + err := service.Vote(ctx, 5, 30, strings.NewReader(tt.vote)) + + if tt.expectRepresentedMeetingUserID != 0 { + if err != nil { + t.Fatalf("Vote returned unexpected error: %v", err) + } + + ds := dsmodels.New(flow) + q := ds.Poll(5) + q = q.Preload(q.BallotList()) + poll, err := q.First(ctx) + if err != nil { + t.Fatalf("Error: Getting votes from poll: %v", err) + } + found := slices.ContainsFunc(poll.BallotList, func(vote dsmodels.Ballot) bool { + userID, _ := vote.RepresentedMeetingUserID.Value() + return userID == tt.expectRepresentedMeetingUserID + }) + + if !found { + t.Errorf("user %d has not voted", tt.expectRepresentedMeetingUserID) + } + + return + } + + if !errors.Is(err, vote.ErrNotAllowed) { + t.Fatalf("Expected NotAllowedError, got: %v", err) + } + }, + ) + }) } } -func TestVotedPollsWithDelegation(t *testing.T) { - ctx := context.Background() - backend := memory.New() - ds := dsmock.NewFlow(dsmock.YAMLData(`--- - poll/1: - backend: memory - type: named - meeting_id: 40 - pollmethod: Y - - user/5: - meeting_user_ids: [10] - meeting_user: - 10: - meeting_id: 8 - vote_delegations_from_ids: [11] - 11: - user_id: 6 - 12: - user_id: 7 - - `)) +func withData(t *testing.T, pg *pgtest.PostgresTest, data string, fn func(service *vote.Vote, flow flow.Flow)) { + t.Helper() - backend.Start(ctx, 1) - backend.Vote(ctx, 1, 5, []byte(`"Y"`)) - backend.Vote(ctx, 1, 6, []byte(`"Y"`)) - backend.Vote(ctx, 1, 7, []byte(`"Y"`)) - v, _, _ := vote.New(ctx, backend, backend, ds, true) + ctx := t.Context() - got, err := v.Voted(ctx, []int{1, 2}, 5) - if err != nil { - t.Fatalf("Voted() returned unexected error: %v", err) + if err := pg.AddData(ctx, data); err != nil { + t.Fatalf("Error: inserting data: %v", err) } - expect := map[int][]int{1: {5, 6}, 2: nil} - if !reflect.DeepEqual(got, expect) { - t.Errorf("Voted() == `%v`, expected `%v`", got, expect) + flow, err := pg.Flow() + if err != nil { + t.Fatalf("Error getting flow: %v", err) } -} + defer flow.Close() -func TestAllLiveVotesIDs_LiveVote_enabled_type_is_named(t *testing.T) { - ctx := context.Background() - backend1 := memory.New() - backend1.Start(ctx, 23) - backend1.Vote(ctx, 23, 1, []byte("vote1")) - backend2 := memory.New() - backend2.Start(ctx, 42) - backend2.Vote(ctx, 42, 1, []byte("vote2")) - backend2.Vote(ctx, 42, 2, []byte("vote3")) - ds := dsmock.NewFlow(dsmock.YAMLData(`--- - poll: - 23: - live_voting_enabled: true - type: named - - title: test_title_04k0y4TwPLpJKaSvIGm1 - onehundred_percent_base: base - pollmethod: YNA - meeting_id: 404 - backend: fast - sequential_number: 404 - content_object_id: assignment/1 - 42: - live_voting_enabled: true - type: named - - title: test_title_04k0y4TwPLpJKaSvIGm1 - onehundred_percent_base: base - pollmethod: YNA - meeting_id: 404 - backend: fast - sequential_number: 404 - content_object_id: assignment/1 - `)) - - v, _, _ := vote.New(ctx, backend1, backend2, ds, true) - - liveVotes := resolvePointers(v.AllLiveVotes(ctx)) - - expect := map[int]map[int]string{23: {1: "vote1"}, 42: {1: "vote2", 2: "vote3"}} - if !reflect.DeepEqual(liveVotes, expect) { - t.Errorf("Got %v, expected %v", liveVotes, expect) + conn, err := pg.Conn(ctx) + if err != nil { + t.Fatalf("Error getting connection: %v", err) } -} + defer conn.Close(ctx) -func resolvePointers(in map[int]map[int]*string) map[int]map[int]string { - out := make(map[int]map[int]string) - for pollID, user2Vote := range in { - out[pollID] = make(map[int]string) - for userID, vote := range user2Vote { - out[pollID][userID] = *vote - } + service, _, err := vote.New(ctx, flow, conn) + if err != nil { + t.Fatalf("Error creating vote: %v", err) } - return out -} -func TestAllLiveVotesIDs_LiveVote_disabled_or_type_is_not_named(t *testing.T) { - ctx := context.Background() - backend1 := memory.New() - backend1.Start(ctx, 23) - backend1.Vote(ctx, 23, 1, []byte("vote1")) - backend2 := memory.New() - backend2.Start(ctx, 42) - backend2.Vote(ctx, 42, 1, []byte("vote2")) - backend2.Vote(ctx, 42, 2, []byte("vote3")) - ds := dsmock.NewFlow(dsmock.YAMLData(`--- - poll: - 23: - - live_voting_enabled: false - type: named - - title: test_title_04k0y4TwPLpJKaSvIGm1 - onehundred_percent_base: base - pollmethod: YNA - meeting_id: 404 - backend: fast - sequential_number: 404 - content_object_id: assignment/1 - 42: - live_voting_enabled: true - type: pseudoanonymous - - title: test_title_04k0y4TwPLpJKaSvIGm1 - onehundred_percent_base: base - pollmethod: YNA - meeting_id: 404 - backend: fast - sequential_number: 404 - content_object_id: assignment/1 - `)) - - v, _, _ := vote.New(ctx, backend1, backend2, ds, true) - - liveVotes := v.AllLiveVotes(ctx) - - expect := map[int]map[int]*string{23: {1: nil}, 42: {1: nil, 2: nil}} - if !reflect.DeepEqual(liveVotes, expect) { - t.Errorf("Got %v, expected %v", liveVotes, expect) - } + fn(service, flow) } diff --git a/vote/vote_validate_test.go b/vote/vote_validate_test.go deleted file mode 100644 index d4a921b0..00000000 --- a/vote/vote_validate_test.go +++ /dev/null @@ -1,358 +0,0 @@ -package vote - -import ( - "encoding/json" - "testing" - - "github.com/OpenSlides/openslides-go/datastore/dsmodels" -) - -func TestVoteValidate(t *testing.T) { - for _, tt := range []struct { - name string - poll dsmodels.Poll - vote string - expectValid bool - }{ - // Test Method Y and N. - { - "Method Y, Global Y, Vote Y", - dsmodels.Poll{ - Pollmethod: "Y", - GlobalYes: true, - }, - `"Y"`, - true, - }, - { - "Method Y, Vote Y", - dsmodels.Poll{ - Pollmethod: "Y", - GlobalYes: false, - }, - `"Y"`, - false, - }, - { - "Method Y, Vote N", - dsmodels.Poll{ - Pollmethod: "Y", - GlobalNo: false, - }, - `"N"`, - false, - }, - { - // The poll config is invalid. A poll with method Y should not allow global_no. - "Method Y, Global N, Vote N", - dsmodels.Poll{ - Pollmethod: "Y", - GlobalNo: true, - }, - `"N"`, - true, - }, - { - "Method N, Global N, Vote N", - dsmodels.Poll{ - Pollmethod: "N", - GlobalNo: true, - }, - `"N"`, - true, - }, - { - "Method Y, Vote Option", - dsmodels.Poll{ - Pollmethod: "Y", - OptionIDs: []int{1, 2}, - }, - `{"1":1}`, - true, - }, - { - "Method Y, Vote on to many Options", - dsmodels.Poll{ - Pollmethod: "Y", - OptionIDs: []int{1, 2}, - }, - `{"1":1,"2":1}`, - false, - }, - { - "Method Y, Vote on one option with to high amount", - dsmodels.Poll{ - Pollmethod: "Y", - OptionIDs: []int{1, 2}, - }, - `{"1":5}`, - false, - }, - { - "Method Y, Vote on many option with to high amount", - dsmodels.Poll{ - Pollmethod: "Y", - OptionIDs: []int{1, 2}, - MaxVotesAmount: 2, - MaxVotesPerOption: 1, - }, - `{"1":1,"2":2}`, - false, - }, - { - "Method Y, Vote on one option with correct amount", - dsmodels.Poll{ - Pollmethod: "Y", - OptionIDs: []int{1, 2}, - MaxVotesAmount: 5, - MaxVotesPerOption: 7, - }, - `{"1":5}`, - true, - }, - { - "Method Y, Vote on one option with to less amount", - dsmodels.Poll{ - Pollmethod: "Y", - OptionIDs: []int{1, 2}, - MinVotesAmount: 10, - MaxVotesAmount: 10, - MaxVotesPerOption: 10, - }, - `{"1":5}`, - false, - }, - { - "Method Y, Vote on many options with to less amount", - dsmodels.Poll{ - Pollmethod: "Y", - OptionIDs: []int{1, 2}, - MinVotesAmount: 10, - }, - `{"1":1,"2":1}`, - false, - }, - { - "Method Y, Vote on one option with -1 amount", - dsmodels.Poll{ - Pollmethod: "Y", - OptionIDs: []int{1, 2}, - }, - `{"1":-1}`, - false, - }, - { - "Method Y, Vote wrong option", - dsmodels.Poll{ - Pollmethod: "Y", - OptionIDs: []int{1, 2}, - }, - `{"5":1}`, - false, - }, - { - "Method Y and maxVotesPerOption>1, Correct vote", - dsmodels.Poll{ - Pollmethod: "Y", - OptionIDs: []int{1, 2, 3, 4}, - MaxVotesAmount: 6, - MaxVotesPerOption: 3, - }, - `{"1":2,"2":0,"3":3,"4":1}`, - true, - }, - { - "Method Y and maxVotesPerOption>1, Too many votes on one option", - dsmodels.Poll{ - Pollmethod: "Y", - OptionIDs: []int{1, 2}, - MaxVotesAmount: 4, - MaxVotesPerOption: 2, - }, - `{"1":3,"2":1}`, - false, - }, - { - "Method Y and maxVotesPerOption>1, Too many votes in total", - dsmodels.Poll{ - Pollmethod: "Y", - OptionIDs: []int{1, 2}, - MaxVotesAmount: 3, - MaxVotesPerOption: 2, - }, - `{"1":2,"2":2}`, - false, - }, - - // Test Method YN and YNA - { - "Method YN, Global Y, Vote Y", - dsmodels.Poll{ - Pollmethod: "YN", - GlobalYes: true, - }, - `"Y"`, - true, - }, - { - "Method YN, Not Global Y, Vote Y", - dsmodels.Poll{ - Pollmethod: "YN", - GlobalYes: false, - }, - `"Y"`, - false, - }, - { - "Method YNA, Global N, Vote N", - dsmodels.Poll{ - Pollmethod: "YNA", - GlobalNo: true, - }, - `"N"`, - true, - }, - { - "Method YNA, Not Global N, Vote N", - dsmodels.Poll{ - Pollmethod: "YNA", - GlobalYes: false, - }, - `"N"`, - false, - }, - { - "Method YNA, Y on Option", - dsmodels.Poll{ - Pollmethod: "YNA", - OptionIDs: []int{1, 2}, - }, - `{"1":"Y"}`, - true, - }, - { - "Method YNA, N on Option", - dsmodels.Poll{ - Pollmethod: "YNA", - OptionIDs: []int{1, 2}, - }, - `{"1":"N"}`, - true, - }, - { - "Method YNA, A on Option", - dsmodels.Poll{ - Pollmethod: "YNA", - OptionIDs: []int{1, 2}, - }, - `{"1":"A"}`, - true, - }, - { - "Method YN, A on Option", - dsmodels.Poll{ - Pollmethod: "YN", - OptionIDs: []int{1, 2}, - }, - `{"1":"A"}`, - false, - }, - { - "Method YN, Y on wrong Option", - dsmodels.Poll{ - Pollmethod: "YN", - OptionIDs: []int{1, 2}, - }, - `{"3":"Y"}`, - false, - }, - { - "Method YNA, Vote on many Options", - dsmodels.Poll{ - Pollmethod: "YNA", - OptionIDs: []int{1, 2, 3}, - }, - `{"1":"Y","2":"N","3":"A"}`, - true, - }, - { - "Method YNA, Amount on Option", - dsmodels.Poll{ - Pollmethod: "YNA", - OptionIDs: []int{1, 2, 3}, - }, - `{"1":1}`, - false, - }, - { - "Method YNA with to low selected", - dsmodels.Poll{ - Pollmethod: "YNA", - OptionIDs: []int{1, 2, 3}, - MinVotesAmount: 2, - }, - `{"1":"Y"}`, - false, - }, - { - "Method YNA with enough selected", - dsmodels.Poll{ - Pollmethod: "YNA", - OptionIDs: []int{1, 2, 3}, - MinVotesAmount: 2, - }, - `{"1":"Y", "2":"N"}`, - true, - }, - { - "Method YNA with to many selected", - dsmodels.Poll{ - Pollmethod: "YNA", - OptionIDs: []int{1, 2, 3}, - MaxVotesAmount: 2, - }, - `{"1":"Y", "2":"N", "3":"A"}`, - false, - }, - { - "Method YNA with not to many selected", - dsmodels.Poll{ - Pollmethod: "YNA", - OptionIDs: []int{1, 2, 3}, - MaxVotesAmount: 2, - }, - `{"1":"Y", "2":"N"}`, - true, - }, - - // Unknown method - { - "Method Unknown", - dsmodels.Poll{ - Pollmethod: "XXX", - }, - `"Y"`, - false, - }, - } { - t.Run(tt.name, func(t *testing.T) { - var b ballot - if err := json.Unmarshal([]byte(tt.vote), &b.Value); err != nil { - t.Fatalf("decoding vote: %v", err) - } - - validation := validate(tt.poll, b.Value) - - if tt.expectValid { - if validation != "" { - t.Fatalf("Validate returned unexpected message: %v", validation) - } - return - } - - if validation == "" { - t.Fatalf("Got no validation error") - } - }) - } -}