├── .dockerignore ├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ ├── on_pull_request.yml │ └── on_push.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE-AGPLv3.txt ├── README.md ├── config ├── config.exs ├── dev.exs ├── prod.exs ├── releases.exs └── test.exs ├── docker-compose.yml ├── lib ├── liquid_voting.ex ├── liquid_voting │ ├── application.ex │ ├── delegations.ex │ ├── delegations │ │ └── delegation.ex │ ├── release.ex │ ├── repo.ex │ ├── voting.ex │ ├── voting │ │ ├── participant.ex │ │ └── vote.ex │ ├── voting_methods.ex │ ├── voting_methods │ │ └── voting_method.ex │ ├── voting_results.ex │ ├── voting_results │ │ └── result.ex │ └── voting_weight.ex ├── liquid_voting_web.ex └── liquid_voting_web │ ├── channels │ └── user_socket.ex │ ├── endpoint.ex │ ├── gettext.ex │ ├── plugs │ └── context.ex │ ├── resolvers │ ├── delegations.ex │ ├── voting.ex │ └── voting_results.ex │ ├── router.ex │ ├── schema │ ├── changeset_errors.ex │ └── schema.ex │ └── views │ ├── error_helpers.ex │ └── error_view.ex ├── mix.exs ├── mix.lock ├── priv ├── gettext │ ├── en │ │ └── LC_MESSAGES │ │ │ └── errors.po │ └── errors.pot └── repo │ ├── migrations │ ├── .formatter.exs │ ├── 20200710123927_create_participants.exs │ ├── 20200710132150_create_votes.exs │ ├── 20200710153620_create_delegations.exs │ ├── 20200710182224_create_results.exs │ ├── 20200821125447_add_delegations_index_org_delegator.exs │ ├── 20200910111541_drop_delegation_uniq_index_org_delegator_delegate.exs │ ├── 20200910131922_create_delegations_uniq_index_org_delegator_delegate_proposal.exs │ ├── 20210329184924_add_voting_methods_table.exs │ ├── 20210330103112_vote_belongs_to_voting_method.exs │ ├── 20210330103629_result_belongs_to_voting_method.exs │ ├── 20210331104227_drop_votes_uniq_index_org_participant_proposal.exs │ ├── 20210331104318_create_votes_uniq_index_org_participant_proposal_voting_method.exs │ ├── 20210331170739_rename_voting_methods_voting_method_to_name.exs │ ├── 20210331171214_drop_voting_methods_uniq_index_org_voting_method.exs │ ├── 20210331171316_create_voting_methods_uniq_index_org_name.exs │ ├── 20210402102314_drop_results_uniq_index_organization_id_proposal_url.exs │ ├── 20210402102530_create_results_uniq_index_org_proposal_voting_method.exs │ ├── 20210405112229_add_voting_methods_default_value_for_name.exs │ ├── 20210405132557_delegation_belongs_to_voting_method.exs │ ├── 20210406174454_drop_delegations_uniq_index_org_delegator_delegate_proposal.exs │ └── 20210406174537_create_delegations_uniq_index_org_delegator_delegate_proposal_voting_method.exs │ └── seeds.exs ├── rel ├── env.bat.eex ├── env.sh.eex └── vm.args.eex ├── scripts └── hello-world-entrypoint.sh └── test ├── liquid_voting ├── delegations_test.exs ├── participants_test.exs ├── voting_methods_test.exs ├── voting_results_test.exs ├── voting_test.exs └── voting_weight_test.exs ├── liquid_voting_web ├── absinthe │ ├── mutations │ │ ├── create_delegation │ │ │ ├── after_creating_related_votes_test.exs │ │ │ ├── before_creating_related_votes_test.exs │ │ │ ├── existing_delegator_delegate_test.exs │ │ │ ├── new_participants_test.exs │ │ │ ├── with_emails │ │ │ │ └── existing_delegations_test.exs │ │ │ └── with_ids │ │ │ │ └── existing_delegations_test.exs │ │ ├── create_participant_test.exs │ │ ├── create_vote_test.exs │ │ ├── delete_delegation_test.exs │ │ └── delete_vote_test.exs │ └── queries │ │ ├── delegations_test.exs │ │ ├── votes_test.exs │ │ └── voting_result_test.exs └── views │ └── error_view_test.exs ├── support ├── channel_case.ex ├── conn_case.ex ├── data_case.ex └── factory.ex └── test_helper.exs /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .github 3 | _build 4 | deps 5 | test 6 | k8s 7 | erl_crash.dump 8 | *.ez 9 | liquid_voting-*.tar 10 | priv/static 11 | .iex.exs 12 | .DS_Store 13 | perftest 14 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :phoenix], 3 | inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | subdirectories: ["priv/*/migrations"] 5 | ] 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "mix" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/workflows/on_pull_request.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | paths-ignore: 8 | - 'README.md' 9 | - 'LICENSE-AGPLv3.txt' 10 | - 'CODE_OF_CONDUCT.md' 11 | - 'CONTRIBUTING.md' 12 | 13 | jobs: 14 | tests: 15 | name: Tests 16 | runs-on: ubuntu-latest 17 | container: 18 | image: elixir:1.11.3 19 | services: 20 | postgres: 21 | image: postgres:10.4 22 | env: 23 | POSTGRES_USER: postgres 24 | POSTGRES_PASSWORD: postgres 25 | POSTGRES_DB: liquid_voting_test 26 | ports: 27 | - 5432:5432 28 | # postgres container doesn't provide a healthcheck 29 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 30 | steps: 31 | - uses: actions/checkout@v1 32 | - name: Install Dependencies 33 | run: | 34 | mix local.rebar --force 35 | mix local.hex --force 36 | mix deps.get 37 | - name: Run Tests 38 | run: mix test 39 | env: 40 | MIX_ENV: test 41 | # refers to the service name, since we're running on containers 42 | POSTGRES_HOST: postgres 43 | 44 | linting: 45 | name: Linting 46 | runs-on: ubuntu-latest 47 | container: 48 | image: elixir:1.11.3 49 | steps: 50 | - uses: actions/checkout@v1 51 | - name: Install Dependencies 52 | run: | 53 | mix local.rebar --force 54 | mix local.hex --force 55 | mix deps.get 56 | - name: Run Formatter 57 | run: mix format --check-formatted 58 | -------------------------------------------------------------------------------- /.github/workflows/on_push.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths-ignore: 8 | - 'README.md' 9 | - 'LICENSE-AGPLv3.txt' 10 | - 'CODE_OF_CONDUCT.md' 11 | - 'CONTRIBUTING.md' 12 | 13 | jobs: 14 | tests: 15 | name: Tests 16 | runs-on: ubuntu-latest 17 | container: 18 | image: elixir:1.11.3 19 | services: 20 | postgres: 21 | image: postgres:10.4 22 | env: 23 | POSTGRES_USER: postgres 24 | POSTGRES_PASSWORD: postgres 25 | POSTGRES_DB: liquid_voting_test 26 | ports: 27 | - 5432:5432 28 | # postgres container doesn't provide a healthcheck 29 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 30 | steps: 31 | - uses: actions/checkout@v1 32 | - name: Install Dependencies 33 | run: | 34 | mix local.rebar --force 35 | mix local.hex --force 36 | mix deps.get 37 | - name: Run Tests 38 | run: mix test 39 | env: 40 | MIX_ENV: test 41 | # refers to the service name, since we're running on containers 42 | POSTGRES_HOST: postgres 43 | 44 | linting: 45 | name: Linting 46 | runs-on: ubuntu-latest 47 | container: 48 | image: elixir:1.11.3 49 | steps: 50 | - uses: actions/checkout@v1 51 | - name: Install Dependencies 52 | run: | 53 | mix local.rebar --force 54 | mix local.hex --force 55 | mix deps.get 56 | - name: Run Formatter 57 | run: mix format --check-formatted 58 | 59 | build-publish: 60 | name: Build & Publish Docker Image 61 | needs: [tests] 62 | runs-on: ubuntu-latest 63 | env: 64 | IMAGE_NAME: ghcr.io/${{ github.repository }} 65 | TAG: ${{ github.sha }} 66 | steps: 67 | - uses: actions/checkout@v1 68 | - name: Build Image 69 | run: | 70 | docker build -t $IMAGE_NAME:$TAG . 71 | docker tag $IMAGE_NAME:$TAG $IMAGE_NAME:latest 72 | - name: Login to Github Container Registry 73 | uses: docker/login-action@v1 74 | with: 75 | registry: ghcr.io 76 | username: ${{ github.repository_owner }} 77 | password: ${{ secrets.CONTAINER_REGISTRY_PAT }} 78 | - name: Publish Image 79 | run: | 80 | docker push $IMAGE_NAME 81 | 82 | deploy: 83 | name: Deploy to Linode 84 | needs: [build-publish] 85 | runs-on: ubuntu-latest 86 | steps: 87 | - uses: actions/checkout@master 88 | - name: Deploy 89 | env: 90 | KUBE_CONFIG_DATA: ${{ secrets.LINODE_KUBE_CONFIG }} 91 | run: | 92 | echo "$KUBE_CONFIG_DATA" | base64 --decode > /tmp/config 93 | export KUBECONFIG=/tmp/config 94 | kubectl rollout restart deployment/api-deployment 95 | pod=$(kubectl get pods -l app=api -o json | jq -r '.items[].metadata.name' | head -1) 96 | kubectl wait --timeout=120s --for=condition=Ready pod/$pod 97 | kubectl exec -i pod/$pod \ 98 | --container api-container \ 99 | -- /opt/app/bin/liquid_voting \ 100 | eval "LiquidVoting.Release.migrate" 101 | 102 | smoke_tests: 103 | name: Smoke Tests 104 | needs: [deploy] 105 | runs-on: ubuntu-latest 106 | steps: 107 | - name: Checkout 108 | uses: actions/checkout@v2 109 | with: 110 | repository: liquidvotingio/deployment 111 | token: ${{ secrets.DEPLOYMENT_PAT }} 112 | path: deployment 113 | - name: Run k6 smoke tests 114 | uses: k6io/action@v0.1 115 | env: 116 | TEST_API_AUTH_KEY: ${{ secrets.TEST_API_AUTH_KEY }} 117 | with: 118 | filename: deployment/smoke_tests/smoke_tests.js 119 | - name: Teardown smoke test data 120 | if: always() 121 | env: 122 | KUBE_CONFIG_DATA: ${{ secrets.LINODE_KUBE_CONFIG }} 123 | run: | 124 | echo "$KUBE_CONFIG_DATA" | base64 --decode > /tmp/config 125 | export KUBECONFIG=/tmp/config 126 | pod=$(kubectl get pods -l app=api -o json | jq -r '.items[].metadata.name' | head -1) 127 | kubectl wait --timeout=120s --for=condition=Ready pod/$pod 128 | kubectl exec -i pod/$pod \ 129 | --container api-container \ 130 | -- /opt/app/bin/liquid_voting \ 131 | eval "LiquidVoting.Release.smoke_test_teardown" 132 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.env 2 | /_build/ 3 | /cover/ 4 | /deps/ 5 | /doc/ 6 | /.fetch 7 | erl_crash.dump 8 | *.ez 9 | liquid_voting-*.tar 10 | /priv/static/ 11 | .iex.exs 12 | .DS_Store 13 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at oli.azevedo.barnes@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | 👍🎉 First off, thanks for taking the time to contribute! 🎉👍 4 | 5 | Issues and pull requests are very welcome for bug reports and feature requests and/or proposals. 6 | 7 | Please provide as much information as possible, describing the bug or feature in detail. Screenshots and videos are great. 8 | 9 | All code needs to include programmatic tests, and to have been tested manually as well. Tests will run on [the repo's CI](https://github.com/liquidvotingio/api/actions), and should pass before asking for a PR review. 10 | 11 | If you're interested in contributing to the project long term (every skill level welcome), let us know [over email](mailto:oli.azevedo.barnes@gmail.com) and we'll onboard you through a call and invite you to our slack. 12 | 13 | Please note that for all communication and interactions we expect our [code of conduct](CODE_OF_CONDUCT.md) to be followed. 14 | 15 | ## Documentation 16 | 17 | Documentation can be found at 18 | 19 | - The [liquidvoting.io](https://liquidvoting.io) website, edited on its [github page repo](https://github.com/liquidvotingio/liquidvotingio.github.io) 20 | - READMES for [the api](https://github.com/liquidvotingio/api) and [auth](https://github.com/liquidvotingio/auth) repos 21 | - Code comments 22 | - Tests 23 | 24 | Keeping docs up to date are part of daily work: whenever we add a new feature, they are part of the delivery. Of course, stuff sometimes falls through the cracks but whenever we notice some documentation is missing, we either add it or create an issue for it in case it's more involved. 25 | 26 | ### doctests and typespecs 27 | 28 | These are currently optional, nice to have. But we're experimenting with them, and they might be fully adopted in the future. 29 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM bitwalker/alpine-elixir:1.11.0 AS release-builder 2 | 3 | # Important! Update this no-op ENV variable when this Dockerfile 4 | # is updated with the current date. It will force refresh of all 5 | # of the base images and things like `apt-get update` won't be using 6 | # old cached versions when the Dockerfile is built. 7 | ENV REFRESHED_AT=2021-01-20 8 | 9 | # Install NPM 10 | RUN \ 11 | mkdir -p /opt/app && \ 12 | chmod -R 777 /opt/app && \ 13 | apk update && \ 14 | apk --no-cache --update add \ 15 | make \ 16 | g++ \ 17 | wget \ 18 | curl \ 19 | inotify-tools \ 20 | nodejs \ 21 | nodejs-npm && \ 22 | npm install npm -g --no-progress && \ 23 | update-ca-certificates --fresh && \ 24 | rm -rf /var/cache/apk/* 25 | 26 | # Add local node module binaries to PATH 27 | ENV PATH=./node_modules/.bin:$PATH 28 | 29 | # Ensure latest versions of Hex/Rebar are installed on build 30 | ONBUILD RUN mix do local.hex --force, local.rebar --force 31 | 32 | ENV MIX_ENV=prod 33 | 34 | WORKDIR /opt/app 35 | 36 | # Cache elixir deps 37 | COPY mix.exs mix.lock ./ 38 | COPY config config 39 | RUN mix deps.get 40 | RUN mix deps.compile 41 | 42 | # Compile project 43 | COPY priv priv 44 | COPY lib lib 45 | RUN mix compile 46 | 47 | # Build release 48 | COPY rel rel 49 | RUN mix release 50 | 51 | # Release image 52 | FROM alpine:3.9 53 | 54 | RUN apk add --no-cache bash libstdc++ openssl 55 | 56 | WORKDIR /opt/app 57 | 58 | COPY --from=release-builder /opt/app/_build/prod/rel/liquid_voting . 59 | 60 | EXPOSE 4000 61 | ENV PORT=4000 MIX_ENV=prod 62 | 63 | ENTRYPOINT ["./bin/liquid_voting"] 64 | 65 | # docker run -e SECRET_KEY_BASE=$(mix phx.gen.secret) -e liquid_voting:latest 66 | CMD ["start"] 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Liquid Voting as a Service 2 | 3 | [![Actions Status](https://github.com/liquidvotingio/api/workflows/CD/badge.svg)](https://github.com/liquidvotingio/api/actions?query=workflow%3ACD) 4 | 5 | A liquid voting service that aims to be easily plugged into proposal-making platforms of different kinds. Learn more about the idea and motivation [on this blog post](https://medium.com/@oliver_azevedo_barnes/liquid-voting-as-a-service-c6e17b81ac1b). 6 | 7 | In this repo there's an Elixir/Phoenix GraphQL API implementing the most basic [liquid democracy](https://en.wikipedia.org/wiki/Liquid_democracy) concepts: participants, proposals, votes and delegations. 8 | 9 | It's deployed on https://api.liquidvoting.io. See sample queries below, in [Using the API](https://github.com/liquidvotingio/api#using-the-api). 10 | 11 | There's [a dockerized version](https://github.com/liquidvotingio/api/packages/81472) of the API. The live API is running on Google Kubernetes Engine. The intention is to make the service easily deployable within a microservices/cloud native context. 12 | 13 | You can follow the [project backlog here](https://github.com/orgs/liquidvotingio/projects/1). 14 | 15 | The live API is getting ready to be used in production platforms. If you're interested, [let us know](mailto:info@liquidvoting.io) so we can learn more about your project, and we'll provide you with an access key right away. 16 | 17 | ## Concepts and modeling 18 | 19 | Participants are users with a name and email, and they can vote on external content (say a blog post, or a pull request), identified as proposal urls, or delegate their votes to another Participant who can then vote for them, or delegate both votes to a third Participant, and so on. 20 | 21 | Votes are yes/no booleans and reference a voter (a Participant) and a proposal_url, and Delegations are references to a delegator (a Participant) and a delegate (another Participant). 22 | 23 | Once each vote is created, delegates' votes will have a different VotingWeight based on how many delegations they've received. 24 | 25 | A VotingResult is calculated taking the votes and their different weights into account. This is a resource the API exposes as a possible `subscription`, for real-time updates over Phoenix Channels. 26 | 27 | The syntax for subscribing, and for all other queries and mutations, can be seen following the setup instructions below. 28 | 29 | ## Local setup 30 | 31 | ### Building from the repo 32 | 33 | You'll need Elixir 1.10, Phoenix 1.4.10 and Postgres 10 installed. 34 | 35 | Clone the repo and: 36 | 37 | ``` 38 | mix deps.get 39 | mix ecto.setup 40 | mix phx.server 41 | ``` 42 | 43 | ### Running the dockerized version 44 | 45 | __Using docker-compose:__ 46 | 47 | Clone the repo and: 48 | 49 | `$ docker-compose up` 50 | 51 | __Running the container:__ 52 | 53 | Mac OSX: 54 | ``` 55 | docker run -it --rm \ 56 | -e SECRET_KEY_BASE=$(mix phx.gen.secret) \ 57 | -e DB_USERNAME=postgres \ 58 | -e DB_PASSWORD=postgres \ 59 | -e DB_NAME=liquid_voting_dev \ 60 | -e DB_HOST=host.docker.internal \ 61 | -p 4000:4000 \ 62 | ghcr.io/liquidvotingio/api:latest 63 | ``` 64 | Linux: 65 | ``` 66 | docker run -it --rm --network=host \ 67 | -e SECRET_KEY_BASE=$(mix phx.gen.secret) \ 68 | -e DB_USERNAME=postgres \ 69 | -e DB_PASSWORD=postgres \ 70 | -e DB_NAME=liquid_voting_dev \ 71 | -e DB_HOST=127.0.0.1 \ 72 | ghcr.io/liquidvotingio/api:latest 73 | ``` 74 | 75 | (assuming you already have the database up and running) 76 | 77 | You can run migrations by passing an `eval` command to the containerized app, like this: 78 | 79 | ``` 80 | docker run -it --rm \ 81 | 82 | ghcr.io/liquidvotingio/api:latest eval "LiquidVoting.Release.migrate" 83 | ``` 84 | 85 | ### Once you're up and running 86 | 87 | Open a GraphiQL window in your browser at http://localhost:4000/graphiql, then configure an `Org-ID` header: 88 | 89 | ```json 90 | { "Org-ID": "b7a9cae5-6e3a-48b1-8730-8b5c8d6c9b5a"} 91 | ``` 92 | 93 | You can then use the queries below to interact with the API. 94 | 95 | ## Using the API 96 | 97 | Create votes and delegations using [GraphQL mutations](https://graphql.org/learn/queries/#mutations) 98 | 99 | ``` 100 | mutation { 101 | createVote(participantEmail: "jane@somedomain.com", proposalUrl:"https://github.com/user/repo/pulls/15", yes: true) { 102 | participant { 103 | email 104 | } 105 | yes 106 | } 107 | } 108 | 109 | mutation { 110 | createDelegation(proposalUrl: "https://github.com/user/repo/pulls/15", delegatorEmail: "nelson@somedomain.com", delegateEmail: "liz@somedomain.com") { 111 | delegator { 112 | email 113 | } 114 | delegate { 115 | email 116 | } 117 | } 118 | } 119 | 120 | mutation { 121 | createVote(participantEmail: "liz@somedomain.com", proposalUrl:"https://github.com/user/repo/pulls/15", yes: false) { 122 | participant { 123 | email 124 | } 125 | yes 126 | votingResult { 127 | inFavor 128 | against 129 | } 130 | } 131 | } 132 | 133 | ``` 134 | 135 | Then run some [queries](https://graphql.org/learn/queries/#fields), inserting valid id values where indicated: 136 | 137 | ``` 138 | query { 139 | participants { 140 | email 141 | id 142 | delegationsReceived { 143 | delegator { 144 | email 145 | } 146 | delegate { 147 | email 148 | } 149 | } 150 | } 151 | } 152 | 153 | query { 154 | participant(id: ) { 155 | email 156 | delegationsReceived { 157 | delegator { 158 | email 159 | } 160 | delegate { 161 | email 162 | } 163 | } 164 | } 165 | } 166 | 167 | query { 168 | votes { 169 | yes 170 | weight 171 | proposalUrl 172 | id 173 | participant { 174 | email 175 | } 176 | } 177 | } 178 | 179 | query { 180 | vote(id: ) { 181 | yes 182 | weight 183 | proposalUrl 184 | participant { 185 | email 186 | } 187 | } 188 | } 189 | 190 | query { 191 | delegations { 192 | id 193 | delegator { 194 | email 195 | } 196 | delegate { 197 | email 198 | } 199 | } 200 | } 201 | 202 | query { 203 | delegation(id: ) { 204 | delegator { 205 | email 206 | } 207 | delegate { 208 | email 209 | } 210 | } 211 | } 212 | 213 | query { 214 | votingResult(proposalUrl: "https://github.com/user/repo/pulls/15") { 215 | inFavor 216 | against 217 | proposalUrl 218 | } 219 | } 220 | ``` 221 | 222 | And [subscribe](https://github.com/absinthe-graphql/absinthe/blob/master/guides/subscriptions.md) to voting results (which will react to voting creation): 223 | 224 | ``` 225 | subscription { 226 | votingResultChange(proposalUrl:"https://github.com/user/repo/pulls/15") { 227 | inFavor 228 | against 229 | proposalUrl 230 | } 231 | } 232 | ``` 233 | 234 | To see this in action, open a second graphiql window in your browser and run `createVote` mutations there, and watch the subscription responses come through on the first one. 235 | 236 | With the examples above, the `inFavor` count should be `1`, and `against` should be `2` since `liz@somedomain.com` had a delegation from `nelson@somedomain.com`. 237 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This configuration file is loaded before any dependency 2 | use Mix.Config 3 | 4 | config :liquid_voting, 5 | ecto_repos: [LiquidVoting.Repo] 6 | 7 | config :liquid_voting, LiquidVoting.Repo, migration_primary_key: [name: :id, type: :binary_id] 8 | 9 | config :liquid_voting, LiquidVotingWeb.Endpoint, 10 | url: [host: "localhost"], 11 | secret_key_base: "JnwIS2wvbN45zqsUjKCgZ1kq8ifWd4lsP08y89uFS5w3uHoFJ9UmdcgiFI3GcKBl", 12 | render_errors: [view: LiquidVotingWeb.ErrorView, accepts: ~w(json)], 13 | pubsub_server: LiquidVoting.PubSub 14 | 15 | config :logger, :console, 16 | format: "$time $metadata[$level] $message\n", 17 | metadata: [:request_id] 18 | 19 | config :phoenix, :json_library, Jason 20 | 21 | # Geometrics ecto config: enables ecto telemetry we use for Honeycomb traces 22 | # Also included in releases.exs, but needed here to prevent error: 23 | # shutdown: failed to start child: Geometrics.OpenTelemetry.Handler 24 | # when running in dev or test environment. 25 | config :geometrics, :ecto_prefix, [:liquid_voting, :repo] 26 | 27 | # Import environment specific config. This must remain at the bottom 28 | # of this file so it overrides the configuration defined above. 29 | import_config "#{Mix.env()}.exs" 30 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Configure your database 4 | config :liquid_voting, LiquidVoting.Repo, 5 | username: "postgres", 6 | password: "postgres", 7 | database: "liquid_voting_dev", 8 | hostname: "localhost", 9 | show_sensitive_data_on_connection_error: true, 10 | pool_size: 10 11 | 12 | # For development, we disable any cache and enable 13 | # debugging and code reloading. 14 | # 15 | # The watchers configuration can be used to run external 16 | # watchers to your application. For example, we use it 17 | # with webpack to recompile .js and .css sources. 18 | config :liquid_voting, LiquidVotingWeb.Endpoint, 19 | http: [port: 4000], 20 | debug_errors: true, 21 | code_reloader: true, 22 | check_origin: false, 23 | watchers: [] 24 | 25 | # ## SSL Support 26 | # 27 | # In order to use HTTPS in development, a self-signed 28 | # certificate can be generated by running the following 29 | # Mix task: 30 | # 31 | # mix phx.gen.cert 32 | # 33 | # Note that this task requires Erlang/OTP 20 or later. 34 | # Run `mix help phx.gen.cert` for more information. 35 | # 36 | # The `http:` config above can be replaced with: 37 | # 38 | # https: [ 39 | # port: 4001, 40 | # cipher_suite: :strong, 41 | # keyfile: "priv/cert/selfsigned_key.pem", 42 | # certfile: "priv/cert/selfsigned.pem" 43 | # ], 44 | # 45 | # If desired, both `http:` and `https:` keys can be 46 | # configured to run both http and https servers on 47 | # different ports. 48 | 49 | # Do not include metadata nor timestamps in development logs 50 | config :logger, :console, format: "[$level] $message\n" 51 | 52 | # Set a higher stacktrace during development. Avoid configuring such 53 | # in production as building large stacktraces may be expensive. 54 | config :phoenix, :stacktrace_depth, 20 55 | 56 | # Initialize plugs at runtime for faster development compilation 57 | config :phoenix, :plug_init_mode, :runtime 58 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :liquid_voting, LiquidVotingWeb.Endpoint, 4 | url: [ 5 | host: System.get_env("APP_HOSTNAME") || "localhost", 6 | port: String.to_integer(System.get_env("APP_PORT") || "4000") 7 | ], 8 | server: true 9 | 10 | config :logger, level: :info 11 | 12 | config :liquid_voting, LiquidVotingWeb.Endpoint, server: true 13 | -------------------------------------------------------------------------------- /config/releases.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | db_user = System.fetch_env!("DB_USERNAME") 4 | db_password = System.fetch_env!("DB_PASSWORD") 5 | db_name = System.fetch_env!("DB_NAME") 6 | db_host = System.fetch_env!("DB_HOST") 7 | db_pool_size = System.get_env("DB_POOL_SIZE") || "10" 8 | 9 | port = System.get_env("APP_PORT") || "4000" 10 | secret_key_base = System.fetch_env!("SECRET_KEY_BASE") 11 | 12 | config :liquid_voting, LiquidVoting.Repo, 13 | username: db_user, 14 | password: db_password, 15 | database: db_name, 16 | hostname: db_host, 17 | pool_size: String.to_integer(db_pool_size) 18 | 19 | config :liquid_voting, LiquidVotingWeb.Endpoint, 20 | http: [:inet6, port: String.to_integer(port)], 21 | secret_key_base: secret_key_base 22 | 23 | # Honeycomb OpenTelemetry exporter config 24 | config :opentelemetry, 25 | processors: [ 26 | otel_batch_processor: %{ 27 | exporter: 28 | {OpenTelemetry.Honeycomb.Exporter, 29 | write_key: System.get_env("HONEYCOMB_WRITEKEY"), dataset: "api-telemetry"} 30 | } 31 | ] 32 | 33 | # Geometrics ecto config: enables ecto telemetry we use for Honeycomb traces 34 | config :geometrics, :ecto_prefix, [:liquid_voting, :repo] 35 | 36 | # to test the release: 37 | # $ MIX_ENV=prod mix release 38 | # $ 39 | # $ SECRET_KEY_BASE=$(mix phx.gen.secret) \ 40 | # $ DB_USERNAME=postgres \ 41 | # $ DB_PASSWORD=postgres \ 42 | # $ DB_NAME=liquid_voting_dev \ 43 | # $ DB_HOST=localhost \ 44 | # $ _build/prod/rel/liquid_voting/bin/liquid_voting start 45 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :liquid_voting, LiquidVoting.Repo, 4 | username: "postgres", 5 | password: "postgres", 6 | database: "liquid_voting_test", 7 | hostname: System.get_env("POSTGRES_HOST") || "localhost", 8 | pool: Ecto.Adapters.SQL.Sandbox 9 | 10 | # We don't run a server during test. If one is required, 11 | # you can enable the server option below. 12 | config :liquid_voting, LiquidVotingWeb.Endpoint, 13 | http: [port: 4002], 14 | server: false 15 | 16 | # Print only warnings and errors during test 17 | config :logger, level: :warn 18 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | liquidvoting: 4 | image: ghcr.io/liquidvotingio/api:latest 5 | entrypoint: ["/tmp/hello-world-entrypoint.sh"] 6 | # The entrypoint override above wipes out the CMD 7 | # on the Dockerfile, so we need to declare it 8 | # again here (https://github.com/docker/compose/issues/3140) 9 | command: ["/opt/app/bin/liquid_voting", "start"] 10 | ports: 11 | - 4000:4000 12 | volumes: 13 | # Makes our entrypoint script available to the container 14 | # under /code/vendor 15 | - ./scripts:/tmp 16 | environment: 17 | - DB_HOST=pg 18 | - DB_POOL_SIZE=10 19 | - DB_NAME=postgres 20 | - DB_USERNAME=postgres 21 | - DB_PASSWORD=postgres 22 | - SECRET_KEY_BASE=super_secret_string 23 | links: 24 | - pg 25 | 26 | pg: 27 | image: postgres 28 | volumes: 29 | - pg-data:/var/lib/postgresql/data 30 | environment: 31 | - POSTGRES_HOST_AUTH_METHOD=trust 32 | volumes: 33 | pg-data: {} 34 | -------------------------------------------------------------------------------- /lib/liquid_voting.ex: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting do 2 | @moduledoc """ 3 | LiquidVoting keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /lib/liquid_voting/application.ex: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | def start(_type, _args) do 9 | import Supervisor.Spec 10 | 11 | OpenTelemetry.register_application_tracer(:liquid_voting) 12 | OpentelemetryPhoenix.setup() 13 | 14 | # List all child processes to be supervised 15 | children = [ 16 | Geometrics.OpenTelemetry.Handler, 17 | LiquidVoting.Repo, 18 | {Phoenix.PubSub, name: LiquidVoting.PubSub}, 19 | LiquidVotingWeb.Endpoint, 20 | supervisor(Absinthe.Subscription, [LiquidVotingWeb.Endpoint]) 21 | ] 22 | 23 | # See https://hexdocs.pm/elixir/Supervisor.html 24 | # for other strategies and supported options 25 | opts = [strategy: :one_for_one, name: LiquidVoting.Supervisor] 26 | Supervisor.start_link(children, opts) 27 | end 28 | 29 | # Tell Phoenix to update the endpoint configuration 30 | # whenever the application is updated. 31 | def config_change(changed, _new, removed) do 32 | LiquidVotingWeb.Endpoint.config_change(changed, removed) 33 | :ok 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/liquid_voting/delegations/delegation.ex: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.Delegations.Delegation do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | alias LiquidVoting.Voting.Participant 6 | alias LiquidVoting.VotingMethods.VotingMethod 7 | 8 | @primary_key {:id, :binary_id, autogenerate: true} 9 | @foreign_key_type :binary_id 10 | 11 | schema "delegations" do 12 | field :proposal_url, EctoFields.URL 13 | field :organization_id, Ecto.UUID 14 | 15 | belongs_to :delegator, Participant 16 | belongs_to :delegate, Participant 17 | belongs_to :voting_method, VotingMethod 18 | 19 | timestamps() 20 | end 21 | 22 | @doc false 23 | def changeset(delegation, attrs) do 24 | required_fields = [:delegator_id, :delegate_id, :organization_id] 25 | all_fields = [:proposal_url, :voting_method_id | required_fields] 26 | 27 | delegation 28 | |> cast(attrs, all_fields) 29 | |> assoc_constraint(:delegator) 30 | |> assoc_constraint(:delegate) 31 | |> validate_required(required_fields) 32 | |> validate_participants_different 33 | |> unique_constraint(:org_delegator_delegate_proposal_method, 34 | name: :uniq_index_org_delegator_delegate_proposal_voting_method 35 | ) 36 | end 37 | 38 | defp validate_participants_different(changeset) do 39 | delegator_id = get_field(changeset, :delegator_id) 40 | delegate_id = get_field(changeset, :delegate_id) 41 | 42 | # we are only checking if non-nil ids match, as validate_required/1 will catch nil case 43 | case delegator_id == delegate_id && delegator_id != nil do 44 | true -> add_error(changeset, :delegate_id, "delegator and delegate must be different") 45 | false -> changeset 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/liquid_voting/release.ex: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.Release do 2 | @app :liquid_voting 3 | @test_organization_id "f30bfc59-d699-4a91-8950-6c6e0169d44a" 4 | 5 | alias LiquidVoting.{Delegations, Voting} 6 | 7 | def migrate do 8 | for repo <- repos() do 9 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) 10 | end 11 | end 12 | 13 | def rollback(repo, version) do 14 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) 15 | end 16 | 17 | @doc """ 18 | Deletes votes, participants and delegations for the smoke tests organization_id. 19 | For use before and after running smoke tests. 20 | """ 21 | def smoke_test_teardown() do 22 | for repo <- repos() do 23 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, fn _repo -> run_teardown() end) 24 | end 25 | end 26 | 27 | defp print_teardown_resources_counts() do 28 | votes_count = fn -> 29 | @test_organization_id 30 | |> Voting.list_votes() 31 | |> Enum.count() 32 | end 33 | 34 | participants_count = fn -> 35 | @test_organization_id 36 | |> Voting.list_participants() 37 | |> Enum.count() 38 | end 39 | 40 | delegations_count = fn -> 41 | @test_organization_id 42 | |> Delegations.list_delegations() 43 | |> Enum.count() 44 | end 45 | 46 | IO.puts( 47 | "#{participants_count.()} Participants, #{votes_count.()} Votes, #{delegations_count.()} Delegations" 48 | ) 49 | end 50 | 51 | defp run_teardown() do 52 | IO.puts("\n## About to run smoke test teardown") 53 | IO.puts("Current resource counts:") 54 | print_teardown_resources_counts() 55 | IO.puts("## Running smoke test teardown on test organization data") 56 | 57 | @test_organization_id 58 | |> Voting.list_votes() 59 | |> Enum.each(fn vote -> Voting.delete_vote!(vote) end) 60 | 61 | @test_organization_id 62 | |> Voting.list_participants() 63 | |> Enum.each(fn participant -> 64 | {:ok, _participant} = Voting.delete_participant(participant) 65 | end) 66 | 67 | @test_organization_id 68 | |> Delegations.list_delegations() 69 | |> Enum.each(fn delegation -> Delegations.delete_delegation!(delegation) end) 70 | 71 | IO.puts("## Teardown ran successfully") 72 | IO.puts("End resource counts:") 73 | print_teardown_resources_counts() 74 | IO.puts("## Have a nice day!\n") 75 | end 76 | 77 | defp repos do 78 | Application.load(@app) 79 | Application.fetch_env!(@app, :ecto_repos) 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/liquid_voting/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.Repo do 2 | use Ecto.Repo, 3 | otp_app: :liquid_voting, 4 | adapter: Ecto.Adapters.Postgres 5 | end 6 | -------------------------------------------------------------------------------- /lib/liquid_voting/voting/participant.ex: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.Voting.Participant do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | alias LiquidVoting.Voting.Vote 6 | alias LiquidVoting.Delegations.Delegation 7 | 8 | @primary_key {:id, :binary_id, autogenerate: true} 9 | 10 | schema "participants" do 11 | field :name, :string 12 | field :email, EctoFields.Email 13 | field :organization_id, Ecto.UUID 14 | 15 | has_many :votes, Vote 16 | has_many :delegations_received, Delegation, foreign_key: :delegate_id 17 | 18 | timestamps() 19 | end 20 | 21 | @doc false 22 | def changeset(participant, attrs) do 23 | required_fields = [:email, :organization_id] 24 | all_fields = [:name | required_fields] 25 | 26 | participant 27 | |> cast(attrs, all_fields) 28 | |> validate_required(required_fields) 29 | |> unique_constraint(:email, name: :uniq_index_organization_id_participant_email) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/liquid_voting/voting/vote.ex: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.Voting.Vote do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | alias LiquidVoting.Voting.Participant 6 | alias LiquidVoting.VotingMethods.VotingMethod 7 | 8 | @primary_key {:id, :binary_id, autogenerate: true} 9 | @foreign_key_type :binary_id 10 | 11 | schema "votes" do 12 | field :yes, :boolean, default: false 13 | field :weight, :integer, default: 1 14 | field :proposal_url, EctoFields.URL 15 | field :organization_id, Ecto.UUID 16 | 17 | belongs_to :participant, Participant 18 | belongs_to :voting_method, VotingMethod 19 | 20 | timestamps() 21 | end 22 | 23 | @doc false 24 | def changeset(vote, attrs) do 25 | required_fields = [ 26 | :yes, 27 | :weight, 28 | :participant_id, 29 | :proposal_url, 30 | :voting_method_id, 31 | :organization_id 32 | ] 33 | 34 | vote 35 | |> cast(attrs, required_fields) 36 | |> assoc_constraint(:participant) 37 | |> assoc_constraint(:voting_method) 38 | |> validate_required(required_fields) 39 | |> unique_constraint(:org_participant_proposal_voting_method, 40 | name: :uniq_index_org_participant_proposal_voting_method 41 | ) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/liquid_voting/voting_methods.ex: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.VotingMethods do 2 | @moduledoc """ 3 | The VotingMethods context. 4 | """ 5 | 6 | import Ecto.Query, warn: false 7 | 8 | alias __MODULE__.VotingMethod 9 | alias LiquidVoting.{Repo} 10 | 11 | @doc """ 12 | Gets a single voting method by id and organization id 13 | 14 | Raises `Ecto.NoResultsError` if the VotingMethod does not exist. 15 | 16 | ## Examples 17 | 18 | iex> get_voting_method!( 19 | "61dbd65c-2c1f-4c29-819c-bbd27112a868", 20 | "a6158b19-6bf6-4457-9d13-ef8b141611b4" 21 | ) 22 | %VotingMethod{} 23 | 24 | iex> get_voting_method!(456, 123) 25 | ** (Ecto.NoResultsError) 26 | 27 | """ 28 | def get_voting_method!(id, organization_id) do 29 | VotingMethod 30 | |> Repo.get_by!(id: id, organization_id: organization_id) 31 | end 32 | 33 | @doc """ 34 | Gets a single voting method by name and organization id 35 | 36 | Raises `Ecto.NoResultsError` if the VotingMethod does not exist. 37 | 38 | ## Examples 39 | 40 | iex> get_voting_method!("our-voting-method", "a6158b19-6bf6-4457-9d13-ef8b141611b4") 41 | %VotingMethod{} 42 | 43 | iex> get_voting_method!("non-existant-method", 456) 44 | ** (Ecto.NoResultsError) 45 | 46 | """ 47 | def get_voting_method_by_name!(name, organization_id) do 48 | VotingMethod 49 | |> Repo.get_by!(name: name, organization_id: organization_id) 50 | end 51 | 52 | def get_voting_method_by_name(name, organization_id) do 53 | VotingMethod 54 | |> Repo.get_by(name: name, organization_id: organization_id) 55 | end 56 | 57 | @doc """ 58 | Returns the list of voting methods for an organization id. 59 | 60 | ## Examples 61 | 62 | iex> list_voting_methods_by_org("a6158b19-6bf6-4457-9d13-ef8b141611b4") 63 | [%VotingMethod{}, ...] 64 | 65 | """ 66 | def list_voting_methods_by_org(organization_id) do 67 | VotingMethod 68 | |> where(organization_id: ^organization_id) 69 | |> Repo.all() 70 | end 71 | 72 | @doc """ 73 | Upserts a voting method (updates or inserts) 74 | 75 | ## Examples 76 | 77 | iex> upsert_voting_method(%{field: value}) 78 | {:ok, %VotingMethod{}} 79 | 80 | iex> upsert_voting_method(%{field: bad_value}) 81 | {:error, %Ecto.Changeset{}} 82 | """ 83 | def upsert_voting_method(attrs \\ %{}) do 84 | %VotingMethod{} 85 | |> VotingMethod.changeset(attrs) 86 | |> Repo.insert_or_update( 87 | on_conflict: {:replace_all_except, [:id]}, 88 | conflict_target: [:organization_id, :name], 89 | returning: true 90 | ) 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/liquid_voting/voting_methods/voting_method.ex: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.VotingMethods.VotingMethod do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | alias LiquidVoting.Voting.Vote 6 | alias LiquidVoting.VotingResults.Result 7 | 8 | @primary_key {:id, :binary_id, autogenerate: true} 9 | @foreign_key_type :binary_id 10 | 11 | schema "voting_methods" do 12 | field :name, :string 13 | field :organization_id, Ecto.UUID 14 | 15 | has_many :votes, Vote 16 | has_many :results, Result 17 | 18 | timestamps() 19 | end 20 | 21 | @doc false 22 | def changeset(voting_method, attrs) do 23 | required_fields = [:organization_id] 24 | all_fields = [:name | required_fields] 25 | 26 | voting_method 27 | |> cast(attrs, all_fields) 28 | |> validate_required(required_fields) 29 | |> unique_constraint(:org_method_name, 30 | name: :uniq_index_org_name 31 | ) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/liquid_voting/voting_results.ex: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.VotingResults do 2 | @moduledoc """ 3 | The VotingResults context. 4 | """ 5 | 6 | require OpenTelemetry.Tracer, as: Tracer 7 | 8 | import Ecto.Query, warn: false 9 | 10 | alias __MODULE__.Result 11 | alias LiquidVoting.{Repo, Voting, VotingWeight} 12 | 13 | @doc """ 14 | Creates or updates voting result based on votes 15 | given to a proposal within the scope of a organization_id 16 | 17 | ## Examples 18 | 19 | iex> calculate_result!( 20 | "https://www.medium/user/eloquent_proposal", 21 | "a6158b19-6bf6-4457-9d13-ef8b141611b4" 22 | ) 23 | %Result{} 24 | 25 | """ 26 | def calculate_result!(voting_method_id, proposal_url, organization_id) do 27 | Tracer.with_span "#{__MODULE__} #{inspect(__ENV__.function)}" do 28 | Tracer.set_attributes([ 29 | {:request_id, Logger.metadata()[:request_id]}, 30 | {:params, 31 | [ 32 | {:organization_id, organization_id}, 33 | {:proposal_url, proposal_url}, 34 | {:voting_method_id, voting_method_id} 35 | ]} 36 | ]) 37 | 38 | votes = Voting.list_votes_by_proposal(voting_method_id, proposal_url, organization_id) 39 | 40 | attrs = %{ 41 | in_favor: 0, 42 | against: 0, 43 | proposal_url: proposal_url, 44 | voting_method_id: voting_method_id, 45 | organization_id: organization_id 46 | } 47 | 48 | attrs = 49 | Enum.reduce(votes, attrs, fn vote, attrs -> 50 | {:ok, vote} = VotingWeight.update_vote_weight(vote) 51 | 52 | if vote.yes do 53 | Map.update!(attrs, :in_favor, &(&1 + vote.weight)) 54 | else 55 | Map.update!(attrs, :against, &(&1 + vote.weight)) 56 | end 57 | end) 58 | 59 | %Result{} 60 | |> Result.changeset(attrs) 61 | |> Repo.insert!( 62 | on_conflict: {:replace_all_except, [:id]}, 63 | conflict_target: [:organization_id, :proposal_url, :voting_method_id], 64 | returning: true 65 | ) 66 | end 67 | end 68 | 69 | @doc """ 70 | Publishes voting result changes to Absinthe's pubsub, so clients can receive updates in real-time 71 | 72 | ## Example 73 | 74 | iex> publish_voting_result_change( 75 | "https://www.medium/user/eloquent_proposal", 76 | "a6158b19-6bf6-4457-9d13-ef8b141611b4" 77 | ) 78 | :ok 79 | 80 | """ 81 | def publish_voting_result_change(voting_method_id, proposal_url, organization_id) do 82 | Tracer.with_span "#{__MODULE__} #{inspect(__ENV__.function)}" do 83 | Tracer.set_attributes([ 84 | {:request_id, Logger.metadata()[:request_id]}, 85 | {:params, 86 | [ 87 | {:organization_id, organization_id}, 88 | {:proposal_url, proposal_url}, 89 | {:voting_method_id, voting_method_id} 90 | ]} 91 | ]) 92 | 93 | result = calculate_result!(voting_method_id, proposal_url, organization_id) 94 | 95 | Absinthe.Subscription.publish( 96 | LiquidVotingWeb.Endpoint, 97 | result, 98 | voting_result_change: proposal_url 99 | ) 100 | end 101 | end 102 | 103 | @doc """ 104 | Publishes voting result changes to Absinthe's pubsub for all results related 105 | to a specific user's votes. 106 | 107 | This is called by both the create_delegation/3 and delete_delegation/3 defs in 108 | the absinthe layer lib/liquid_voting/reolvers/delegations.ex file. This ensures 109 | that related voting results AND Absinthe's pubsub are updated accordingly. 110 | 111 | ## Example 112 | 113 | iex> publish_voting_result_changes_for_participant( 114 | "377ead47-05f1-46b5-a676-f13b619623a7", 115 | "a6158b19-6bf6-4457-9d13-ef8b141611b4" 116 | ) 117 | :ok 118 | 119 | """ 120 | def publish_voting_result_changes_for_participant(participant_id, organization_id) do 121 | Voting.list_votes_by_participant(participant_id, organization_id) 122 | |> Enum.each(fn vote -> 123 | publish_voting_result_change(vote.voting_method_id, vote.proposal_url, organization_id) 124 | end) 125 | end 126 | 127 | @doc """ 128 | Returns the list of results in the scope of a organization_id. 129 | 130 | ## Examples 131 | 132 | iex> list_results("a6158b19-6bf6-4457-9d13-ef8b141611b4") 133 | [%Result{}, ...] 134 | 135 | """ 136 | def list_results(organization_id) do 137 | Result 138 | |> where(organization_id: ^organization_id) 139 | |> Repo.all() 140 | |> Repo.preload([:voting_method]) 141 | end 142 | 143 | @doc """ 144 | Returns the list of results for a proposal_url, in the scope of a organization_id. 145 | 146 | ## Examples 147 | 148 | iex> list_results("https://our-proposals/proposal1", "a6158b19-6bf6-4457-9d13-ef8b141611b4") 149 | [%Result{}, ...] 150 | 151 | """ 152 | def list_results_for_proposal_url(proposal_url, organization_id) do 153 | Result 154 | |> where(organization_id: ^organization_id, proposal_url: ^proposal_url) 155 | |> Repo.all() 156 | |> Repo.preload([:voting_method]) 157 | end 158 | 159 | @doc """ 160 | Gets a single result for an organization_id 161 | 162 | Raises `Ecto.NoResultsError` if the Result does not exist. 163 | 164 | ## Examples 165 | 166 | iex> get_result!( 167 | "ec15b5d3-bfff-4ca6-a56a-78a460b2d38f", 168 | "a6158b19-6bf6-4457-9d13-ef8b141611b4" 169 | ) 170 | %Result{} 171 | 172 | iex> get_result!( 173 | "1a1d0de6-1706-4a8e-8e34-d6aea3fa9e19", 174 | "a6158b19-6bf6-4457-9d13-ef8b141611b4" 175 | ) 176 | ** (Ecto.NoResultsError) 177 | 178 | """ 179 | def get_result!(id, organization_id) do 180 | Repo.get_by!(Result, id: id, organization_id: organization_id) 181 | |> Repo.preload([:voting_method]) 182 | end 183 | 184 | @doc """ 185 | Gets a single result by its voting_method_id, proposal url and organization_id 186 | 187 | Returns `nil` if the Result does not exist. 188 | 189 | ## Examples 190 | 191 | iex> get_result_by_proposal_url( 192 | "377ead47-05f1-46b5-a676-f13b619623a7", 193 | "https://www.myproposal.com/", 194 | "a6158b19-6bf6-4457-9d13-ef8b141611b4" 195 | ) 196 | %Result{} 197 | 198 | iex> get_result_by_proposal_url( 199 | "377ead47-05f1-46b5-a676-f13b619623a7", 200 | "https://nonexistentproposal.com/", 201 | "a6158b19-6bf6-4457-9d13-ef8b141611b4" 202 | ) 203 | nil 204 | 205 | """ 206 | def get_result_by_proposal_url(voting_method_id, proposal_url, organization_id) do 207 | Repo.get_by(Result, 208 | voting_method_id: voting_method_id, 209 | proposal_url: proposal_url, 210 | organization_id: organization_id 211 | ) 212 | |> Repo.preload([:voting_method]) 213 | end 214 | 215 | @doc """ 216 | Creates a result. 217 | 218 | ## Examples 219 | 220 | iex> create_result(%{field: value}) 221 | {:ok, %Result{}} 222 | 223 | iex> create_result(%{field: bad_value}) 224 | {:error, %Ecto.Changeset{}} 225 | 226 | """ 227 | def create_result(attrs \\ %{}) do 228 | %Result{} 229 | |> Result.changeset(attrs) 230 | |> Repo.insert() 231 | end 232 | 233 | @doc """ 234 | Creates a result. 235 | 236 | ## Examples 237 | 238 | iex> create_result(%{field: value}) 239 | %Result{} 240 | 241 | iex> create_result(%{field: bad_value}) 242 | Ecto.*Error 243 | 244 | """ 245 | def create_result!(attrs \\ %{}) do 246 | %Result{} 247 | |> Result.changeset(attrs) 248 | |> Repo.insert!() 249 | end 250 | end 251 | -------------------------------------------------------------------------------- /lib/liquid_voting/voting_results/result.ex: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.VotingResults.Result do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | alias LiquidVoting.VotingMethods.VotingMethod 6 | 7 | @primary_key {:id, :binary_id, autogenerate: true} 8 | @foreign_key_type :binary_id 9 | 10 | schema "results" do 11 | field :in_favor, :integer, default: 0 12 | field :against, :integer, default: 0 13 | field :proposal_url, :string 14 | field :organization_id, Ecto.UUID 15 | 16 | belongs_to :voting_method, VotingMethod 17 | 18 | timestamps() 19 | end 20 | 21 | @doc false 22 | def changeset(result, attrs) do 23 | required_fields = [:voting_method_id, :proposal_url, :organization_id] 24 | all_fields = [:in_favor | [:against | required_fields]] 25 | 26 | result 27 | |> cast(attrs, all_fields) 28 | |> validate_required(required_fields) 29 | |> unique_constraint(:org_proposal_voting_method, 30 | name: :uniq_index_org_proposal_voting_method 31 | ) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/liquid_voting/voting_weight.ex: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.VotingWeight do 2 | @moduledoc """ 3 | The VotingWeight context. 4 | """ 5 | 6 | import Ecto.Query, warn: false 7 | 8 | alias LiquidVoting.{Repo, Voting} 9 | 10 | @doc """ 11 | Updates vote weight based on delegations given to voter 12 | 13 | ## Examples 14 | 15 | iex> update_vote_weight(vote) 16 | {:ok, %Vote{}} 17 | 18 | iex> update_vote_weight(vote) 19 | {:error, %Ecto.Changeset{}} 20 | 21 | """ 22 | def update_vote_weight(vote) do 23 | # :force it because sometimes votes come in with stale associations 24 | vote = Repo.preload(vote, [participant: :delegations_received], force: true) 25 | voter = vote.participant 26 | 27 | weight = 28 | 1 + delegation_weight(voter.delegations_received, vote.proposal_url, vote.voting_method_id) 29 | 30 | Voting.update_vote(vote, %{weight: weight}) 31 | end 32 | 33 | # If no weight is given, adds default of 0 for it, and recurses with 34 | # delegation_weight/3. This is the starting point for traversing 35 | # a voter's delegation tree and incrementing the voting weight 36 | defp delegation_weight(delegations, proposal_url, voting_method_id, weight \\ 0) 37 | 38 | # Traverse up delegation tree and add up the accumulated weight. 39 | # If you're wondering, [_|_] matches a non-empty array. Equivalent to 40 | # matching [head|tail] but ignoring both head and tail variables 41 | defp delegation_weight(delegations = [_ | _], proposal_url, voting_method_id, weight) do 42 | # TODO: Do this in SQL 43 | # 44 | # Not sure yet which tree/hierarchy handling pattern would fit here, 45 | # given there's two tables involved instead of a single one nested onto itself as 46 | # in most threaded Comments or Org Chart examples. 47 | # 48 | # Recursive Query might be worth a try, check back with Bill Karwin's SQL Antipatterns book 49 | # 50 | # END TODO 51 | # 52 | # Add-up 1 unit of weight for each delegation, 53 | # then recurse on each delegators' own delegations 54 | Enum.reduce(delegations, weight, fn delegation, weight -> 55 | # Only proceed if delegation is global or is meant for the 56 | # proposal being voted on: 57 | if (delegation.proposal_url == proposal_url && 58 | delegation.voting_method_id == voting_method_id) || delegation.proposal_url == nil do 59 | delegation = Repo.preload(delegation, delegator: :delegations_received) 60 | delegator = delegation.delegator 61 | 62 | weight = weight + 1 63 | 64 | delegation_weight(delegator.delegations_received, proposal_url, voting_method_id, weight) 65 | 66 | # If delegation is for a different proposal, just return the unchanged weight 67 | else 68 | weight 69 | end 70 | end) 71 | end 72 | 73 | # Base case for the above recursion: 74 | # 75 | # If no delegations left, just return the latest weight 76 | defp delegation_weight(_ = [], _, _, weight) do 77 | weight 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/liquid_voting_web.ex: -------------------------------------------------------------------------------- 1 | defmodule LiquidVotingWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, views, channels and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use LiquidVotingWeb, :controller 9 | use LiquidVotingWeb, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define any helper function in modules 17 | and import those modules here. 18 | """ 19 | 20 | def controller do 21 | quote do 22 | use Phoenix.Controller, namespace: LiquidVotingWeb 23 | 24 | import Plug.Conn 25 | import LiquidVotingWeb.Gettext 26 | 27 | alias LiquidVotingWeb.Router.Helpers, as: Routes 28 | end 29 | end 30 | 31 | def view do 32 | quote do 33 | use Phoenix.View, 34 | root: "lib/liquid_voting_web/templates", 35 | namespace: LiquidVotingWeb 36 | 37 | # Import convenience functions from controllers 38 | import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1] 39 | import LiquidVotingWeb.ErrorHelpers 40 | import LiquidVotingWeb.Gettext 41 | 42 | alias LiquidVotingWeb.Router.Helpers, as: Routes 43 | end 44 | end 45 | 46 | def router do 47 | quote do 48 | use Phoenix.Router 49 | 50 | import Plug.Conn 51 | import Phoenix.Controller 52 | end 53 | end 54 | 55 | def channel do 56 | quote do 57 | use Phoenix.Channel 58 | import LiquidVotingWeb.Gettext 59 | end 60 | end 61 | 62 | @doc """ 63 | When used, dispatch to the appropriate controller/view/etc. 64 | """ 65 | defmacro __using__(which) when is_atom(which), do: apply(__MODULE__, which, []) 66 | end 67 | -------------------------------------------------------------------------------- /lib/liquid_voting_web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule LiquidVotingWeb.UserSocket do 2 | use Phoenix.Socket 3 | use Absinthe.Phoenix.Socket, schema: LiquidVotingWeb.Schema.Schema 4 | 5 | ## Channels 6 | # channel "room:*", LiquidVotingWeb.RoomChannel 7 | 8 | # Socket params are passed from the client and can 9 | # be used to verify and authenticate a user. After 10 | # verification, you can put default assigns into 11 | # the socket that will be set for all channels, ie 12 | # 13 | # {:ok, assign(socket, :user_id, verified_user_id)} 14 | # 15 | # To deny connection, return `:error`. 16 | # 17 | # See `Phoenix.Token` documentation for examples in 18 | # performing token verification on connect. 19 | def connect(_params, socket, _connect_info), do: {:ok, socket} 20 | 21 | # Socket id's are topics that allow you to identify all sockets for a given user: 22 | # 23 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}" 24 | # 25 | # Would allow you to broadcast a "disconnect" event and terminate 26 | # all active sockets and channels for a given user: 27 | # 28 | # LiquidVotingWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) 29 | # 30 | # Returning `nil` makes this socket anonymous. 31 | def id(_socket), do: nil 32 | end 33 | -------------------------------------------------------------------------------- /lib/liquid_voting_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule LiquidVotingWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :liquid_voting 3 | use Absinthe.Phoenix.Endpoint 4 | 5 | socket "/socket", LiquidVotingWeb.UserSocket, 6 | websocket: true, 7 | longpoll: false 8 | 9 | # Serve at "/" the static files from "priv/static" directory. 10 | # 11 | # You should set gzip to true if you are running phx.digest 12 | # when deploying your static files in production. 13 | plug Plug.Static, 14 | at: "/", 15 | from: :liquid_voting, 16 | gzip: false, 17 | only: ~w(css fonts images js favicon.ico robots.txt) 18 | 19 | # Code reloading can be explicitly enabled under the 20 | # :code_reloader configuration of your endpoint. 21 | if code_reloading? do 22 | plug Phoenix.CodeReloader 23 | end 24 | 25 | plug Plug.RequestId 26 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 27 | 28 | plug Plug.Parsers, 29 | parsers: [:urlencoded, :multipart, :json], 30 | pass: ["*/*"], 31 | json_decoder: Phoenix.json_library() 32 | 33 | plug Plug.MethodOverride 34 | plug Plug.Head 35 | 36 | # The session will be stored in the cookie and signed, 37 | # this means its contents can be read but not tampered with. 38 | # Set :encryption_salt if you would also like to encrypt it. 39 | plug Plug.Session, 40 | store: :cookie, 41 | key: "_liquid_voting_key", 42 | signing_salt: "NW9CQJMd" 43 | 44 | plug Corsica, origins: "*", allow_headers: :all 45 | 46 | plug LiquidVotingWeb.Router 47 | end 48 | -------------------------------------------------------------------------------- /lib/liquid_voting_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule LiquidVotingWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import LiquidVotingWeb.Gettext 9 | 10 | # Simple translation 11 | gettext("Here is the string to translate") 12 | 13 | # Plural translation 14 | ngettext("Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3) 17 | 18 | # Domain-based translation 19 | dgettext("errors", "Here is the error message to translate") 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :liquid_voting 24 | end 25 | -------------------------------------------------------------------------------- /lib/liquid_voting_web/plugs/context.ex: -------------------------------------------------------------------------------- 1 | defmodule LiquidVotingWeb.Plugs.Context do 2 | @behaviour Plug 3 | 4 | import Plug.Conn 5 | 6 | def init(opts), do: opts 7 | 8 | def call(conn, _) do 9 | context = build_context(conn) 10 | # Absinthe.Plug calls Absinthe.run() with the options added to the `conn`. 11 | Absinthe.Plug.put_options(conn, context: context) 12 | end 13 | 14 | @doc """ 15 | Return the organization id context based on the org-id header 16 | """ 17 | def build_context(conn) do 18 | with [organization_id] <- get_req_header(conn, "org-id") do 19 | %{organization_id: organization_id} 20 | else 21 | _ -> %{} 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/liquid_voting_web/resolvers/delegations.ex: -------------------------------------------------------------------------------- 1 | defmodule LiquidVotingWeb.Resolvers.Delegations do 2 | alias LiquidVoting.{Voting, Delegations, VotingMethods, VotingResults} 3 | alias LiquidVotingWeb.Schema.ChangesetErrors 4 | 5 | def delegations(_, _, %{context: %{organization_id: organization_id}}), 6 | do: {:ok, Delegations.list_delegations(organization_id)} 7 | 8 | def delegation(_, %{id: id}, %{context: %{organization_id: organization_id}}), 9 | do: {:ok, Delegations.get_delegation!(id, organization_id)} 10 | 11 | @doc """ 12 | Creates a delegation. 13 | 14 | Adds participants to the db if they don't exist, or fetches them if they do. 15 | 16 | Valid arguments (args) must include 2 ids (delegator_id and delegate_id) 17 | OR 2 emails (delegator_email and delegate_email). 18 | 19 | Valid arguments (args) may include a proposal_url. 20 | Without a proposal_url, an attempt to create a global delegation will occur. 21 | 22 | The participant ids, either directly taken from delegator_id and 23 | delegate_id, or via searching the db for participants with the emails 24 | provided, are used when inserting the delegation with 25 | LiquidVoting.Delegations.create_delegation/1. 26 | 27 | ## Examples 28 | 29 | iex> create_delegation( 30 | %{}, 31 | %{delegator_email: "alice@somemail.com", 32 | delegate_email: "bob@somemail.com", 33 | proposal_url: "https://proposalplace/proposal63"}, 34 | %{context: %{organization_id: "b212ef83-d3df-4a7a-8875-36cca613e8d6"}}) 35 | %Delegation{} 36 | 37 | iex> create_delegation( 38 | %{}, 39 | %{delegator_email: "alice@somemail.com"}, 40 | %{context: %{organization_id: "b212ef83-d3df-4a7a-8875-36cca613e8d6"}}) 41 | {:error, 42 | %{ 43 | details: %{delegate_email: ["can't be blank"]}, 44 | message: "Could not create delegation" 45 | }} 46 | """ 47 | def create_delegation(_, args, %{context: %{organization_id: organization_id}}) do 48 | args = Map.put(args, :organization_id, organization_id) 49 | 50 | with {:ok, args} <- validate_participant_args(args), 51 | {:ok, args} <- validate_delegation_type(args), 52 | {:ok, delegation} <- Delegations.create_delegation(args) do 53 | proposal_url = Map.get(args, :proposal_url) 54 | 55 | case proposal_url do 56 | # Global delegation: We find all votes of the delegate and update related voting result(s). 57 | nil -> 58 | VotingResults.publish_voting_result_changes_for_participant( 59 | delegation.delegate_id, 60 | delegation.organization_id 61 | ) 62 | 63 | # Proposal delegation: Publish result change for result with same proposal_url && voting_method 64 | _proposal_url -> 65 | VotingResults.publish_voting_result_change( 66 | delegation.voting_method.id, 67 | proposal_url, 68 | delegation.organization_id 69 | ) 70 | end 71 | 72 | {:ok, delegation} 73 | else 74 | {:error, message: message, details: details} -> 75 | {:error, message: message, details: details} 76 | 77 | {:error, %{message: message, details: details}} -> 78 | {:error, %{message: message, details: details}} 79 | 80 | {:error, changeset} -> 81 | {:error, 82 | message: "Could not create delegation", details: ChangesetErrors.error_details(changeset)} 83 | 84 | {:error, name, changeset, _} -> 85 | {:error, 86 | message: "Could not create #{name}", details: ChangesetErrors.error_details(changeset)} 87 | end 88 | end 89 | 90 | defp validate_participant_args(args) do 91 | case args do 92 | %{delegator_email: _, delegate_email: _} -> 93 | {:ok, args} 94 | 95 | %{delegator_id: _, delegate_id: _} -> 96 | {:ok, args} 97 | 98 | # delegator_email field provided, but no delegate_email field provided. 99 | %{delegator_email: _} -> 100 | field_not_found_error(%{delegate_email: ["can't be blank"]}) 101 | 102 | # delegate_email field provided, but no delegator_email field provided. 103 | %{delegate_email: _} -> 104 | field_not_found_error(%{delegator_email: ["can't be blank"]}) 105 | 106 | # delegator_id field provided, but no delegate_id field provided. 107 | %{delegator_id: _} -> 108 | field_not_found_error(%{delegate_id: ["can't be blank"]}) 109 | 110 | # delegate_id field provided, but no delegator_id field provided. 111 | %{delegate_id: _} -> 112 | field_not_found_error(%{delegator_id: ["can't be blank"]}) 113 | 114 | # No id or email fields provided for delegator and delegate. 115 | _ -> 116 | field_not_found_error("emails or ids identifying delegator and delegate can't be blank") 117 | end 118 | end 119 | 120 | defp validate_delegation_type(args) do 121 | case args do 122 | # In combination, these fields specify a delegation for a proposal_url and related voting_method. 123 | %{voting_method: _, proposal_url: _} -> 124 | {:ok, args} 125 | 126 | # A voting_method value of "default" will be used when no voting_method is specified. 127 | %{proposal_url: _} -> 128 | {:ok, args} 129 | 130 | # voting_method field provided, but no proposal_url field provided. 131 | %{voting_method: _} -> 132 | field_not_found_error(%{proposal_url: ["can't be blank, if voting_method specified"]}) 133 | 134 | # Global delegations use neither a proposal_url nor a voting_method. 135 | _ -> 136 | {:ok, args} 137 | end 138 | end 139 | 140 | defp field_not_found_error(details) do 141 | {:error, 142 | %{ 143 | message: "Could not create delegation", 144 | details: details 145 | }} 146 | end 147 | 148 | # Delete proposal-specific delegation 149 | def delete_delegation( 150 | _, 151 | %{ 152 | delegator_email: delegator_email, 153 | delegate_email: delegate_email, 154 | proposal_url: proposal_url 155 | } = args, 156 | %{context: %{organization_id: organization_id}} 157 | ) do 158 | voting_method_name = Map.get(args, :voting_method) || "default" 159 | 160 | deleted_delegation = 161 | Delegations.get_delegation!( 162 | delegator_email, 163 | delegate_email, 164 | voting_method_name, 165 | proposal_url, 166 | organization_id 167 | ) 168 | |> Delegations.delete_delegation!() 169 | 170 | VotingResults.publish_voting_result_change( 171 | deleted_delegation.voting_method.id, 172 | proposal_url, 173 | organization_id 174 | ) 175 | 176 | {:ok, deleted_delegation} 177 | rescue 178 | Ecto.NoResultsError -> {:error, message: "No delegation found to delete"} 179 | end 180 | 181 | # Delete global delegation 182 | def delete_delegation(_, %{delegator_email: delegator_email, delegate_email: delegate_email}, %{ 183 | context: %{organization_id: organization_id} 184 | }) do 185 | deleted_delegation = 186 | Delegations.get_delegation!(delegator_email, delegate_email, organization_id) 187 | |> Delegations.delete_delegation!() 188 | 189 | delegate = Voting.get_participant_by_email(delegate_email, organization_id) 190 | 191 | VotingResults.publish_voting_result_changes_for_participant( 192 | delegate.id, 193 | organization_id 194 | ) 195 | 196 | {:ok, deleted_delegation} 197 | rescue 198 | Ecto.NoResultsError -> {:error, message: "No delegation found to delete"} 199 | end 200 | end 201 | -------------------------------------------------------------------------------- /lib/liquid_voting_web/resolvers/voting.ex: -------------------------------------------------------------------------------- 1 | defmodule LiquidVotingWeb.Resolvers.Voting do 2 | require OpenTelemetry.Tracer, as: Tracer 3 | 4 | alias LiquidVoting.{Repo, Voting, VotingMethods, VotingResults} 5 | alias LiquidVotingWeb.Schema.ChangesetErrors 6 | alias Ecto.Multi 7 | 8 | def participants(_, _, %{context: %{organization_id: organization_id}}), 9 | do: {:ok, Voting.list_participants(organization_id)} 10 | 11 | def participant(_, %{id: id}, %{context: %{organization_id: organization_id}}), 12 | do: {:ok, Voting.get_participant!(id, organization_id)} 13 | 14 | def create_participant(_, args, %{context: %{organization_id: organization_id}}) do 15 | args = Map.put(args, :organization_id, organization_id) 16 | 17 | case Voting.create_participant(args) do 18 | {:error, changeset} -> 19 | {:error, 20 | message: "Could not create participant", 21 | details: ChangesetErrors.error_details(changeset)} 22 | 23 | {:ok, participant} -> 24 | {:ok, participant} 25 | end 26 | end 27 | 28 | def votes(_, %{proposal_url: proposal_url} = args, %{ 29 | context: %{organization_id: organization_id} 30 | }) do 31 | voting_method_name = Map.get(args, :voting_method) || "default" 32 | voting_method = VotingMethods.get_voting_method_by_name(voting_method_name, organization_id) 33 | 34 | case voting_method do 35 | nil -> {:ok, []} 36 | _ -> {:ok, Voting.list_votes_by_proposal(voting_method.id, proposal_url, organization_id)} 37 | end 38 | end 39 | 40 | def votes(_, %{voting_method: _voting_method}, _), 41 | do: {:error, message: "A proposal url must also be given when a voting method is specified"} 42 | 43 | def votes(_, _, %{context: %{organization_id: organization_id}}) do 44 | Tracer.with_span "#{__MODULE__} #{inspect(__ENV__.function)}" do 45 | Tracer.set_attributes([ 46 | {:request_id, Logger.metadata()[:request_id]}, 47 | {:params, [{:organization_id, organization_id}]} 48 | ]) 49 | 50 | {:ok, Voting.list_votes(organization_id)} 51 | end 52 | end 53 | 54 | def vote(_, %{id: id}, %{context: %{organization_id: organization_id}}), 55 | do: {:ok, Voting.get_vote!(id, organization_id)} 56 | 57 | def create_vote( 58 | _, 59 | %{participant_email: email, proposal_url: _, yes: _} = args, 60 | %{ 61 | context: %{organization_id: organization_id} 62 | } 63 | ) do 64 | voting_method_name = Map.get(args, :voting_method) 65 | 66 | Tracer.with_span "#{__MODULE__} #{inspect(__ENV__.function)}" do 67 | Tracer.set_attributes([ 68 | {:request_id, Logger.metadata()[:request_id]}, 69 | {:params, 70 | [ 71 | {:organization_id, organization_id}, 72 | {:email, email}, 73 | {:voting_method, voting_method_name} 74 | ]} 75 | ]) 76 | 77 | Multi.new() 78 | |> Multi.run(:upsert_voting_method, fn _repo, _changes -> 79 | VotingMethods.upsert_voting_method(%{ 80 | organization_id: organization_id, 81 | name: voting_method_name 82 | }) 83 | end) 84 | |> Multi.run(:upsert_participant, fn _repo, _changes -> 85 | Voting.upsert_participant(%{email: email, organization_id: organization_id}) 86 | end) 87 | |> Multi.run(:create_vote, fn _repo, changes -> 88 | args 89 | |> Map.put(:organization_id, organization_id) 90 | |> Map.put(:voting_method_id, changes.upsert_voting_method.id) 91 | |> Map.put(:participant_id, changes.upsert_participant.id) 92 | |> create_vote_with_valid_arguments() 93 | end) 94 | |> Repo.transaction() 95 | |> case do 96 | {:ok, resources} -> 97 | {:ok, resources.create_vote} 98 | 99 | {:error, action, changeset, _} -> 100 | changeset = Ecto.Changeset.add_error(changeset, :action, "#{action}") 101 | 102 | {:error, 103 | message: "Could not create vote", details: ChangesetErrors.error_details(changeset)} 104 | 105 | error -> 106 | error 107 | end 108 | end 109 | end 110 | 111 | def create_vote( 112 | _, 113 | %{participant_id: _, proposal_url: _, yes: _, voting_method: voting_method} = args, 114 | %{ 115 | context: %{organization_id: organization_id} 116 | } 117 | ) do 118 | case VotingMethods.upsert_voting_method(%{ 119 | organization_id: organization_id, 120 | name: voting_method 121 | }) do 122 | {:error, changeset} -> 123 | changeset = Ecto.Changeset.add_error(changeset, :action, "voting_method") 124 | 125 | {:error, 126 | message: "Could not create voting method", 127 | details: ChangesetErrors.error_details(changeset)} 128 | 129 | {:ok, voting_method} -> 130 | args 131 | |> Map.put(:organization_id, organization_id) 132 | |> Map.put(:voting_method_id, voting_method.id) 133 | |> create_vote_with_valid_arguments() 134 | end 135 | end 136 | 137 | def create_vote(_, %{proposal_url: _, yes: _, voting_method: _}, _), 138 | do: 139 | {:error, 140 | message: "Could not create vote", 141 | details: "No participant identifier (id or email) submitted"} 142 | 143 | defp create_vote_with_valid_arguments(args) do 144 | Tracer.with_span "#{__MODULE__} #{inspect(__ENV__.function)}" do 145 | Tracer.set_attributes([ 146 | {:request_id, Logger.metadata()[:request_id]}, 147 | {:params, 148 | [ 149 | {:organization_id, args[:organization_id]}, 150 | {:participant_email, args[:participant_email]}, 151 | {:participant_id, args[:participant_id]}, 152 | {:proposal_url, args[:proposal_url]}, 153 | {:voting_method, args[:voting_method]}, 154 | {:voting_method_id, args[:voting_method_id]}, 155 | {:yes, args[:yes]} 156 | ]} 157 | ]) 158 | 159 | case Voting.create_vote(args) do 160 | {:error, changeset} -> 161 | {:error, changeset} 162 | 163 | {:ok, vote} -> 164 | Tracer.set_attributes([{:result, ":ok, Voting.create_vote"}]) 165 | 166 | VotingResults.publish_voting_result_change( 167 | vote.voting_method_id, 168 | vote.proposal_url, 169 | vote.organization_id 170 | ) 171 | 172 | {:ok, vote} 173 | end 174 | end 175 | end 176 | 177 | def delete_vote( 178 | _, 179 | %{ 180 | participant_email: email, 181 | proposal_url: proposal_url 182 | } = args, 183 | %{ 184 | context: %{organization_id: organization_id} 185 | } 186 | ) do 187 | voting_method_name = Map.get(args, :voting_method) || "default" 188 | 189 | voting_method = VotingMethods.get_voting_method_by_name!(voting_method_name, organization_id) 190 | 191 | deleted_vote = 192 | Voting.get_vote!(email, voting_method.id, proposal_url, organization_id) 193 | |> Voting.delete_vote!() 194 | 195 | VotingResults.publish_voting_result_change( 196 | voting_method.id, 197 | deleted_vote.proposal_url, 198 | deleted_vote.organization_id 199 | ) 200 | 201 | {:ok, deleted_vote} 202 | rescue 203 | Ecto.NoResultsError -> {:error, message: "No vote found to delete"} 204 | end 205 | end 206 | -------------------------------------------------------------------------------- /lib/liquid_voting_web/resolvers/voting_results.ex: -------------------------------------------------------------------------------- 1 | defmodule LiquidVotingWeb.Resolvers.VotingResults do 2 | alias LiquidVoting.{VotingMethods, VotingResults} 3 | 4 | def result(_, %{proposal_url: proposal_url} = args, %{ 5 | context: %{organization_id: organization_id} 6 | }) do 7 | voting_method_name = Map.get(args, :voting_method) || "default" 8 | voting_method = VotingMethods.get_voting_method_by_name!(voting_method_name, organization_id) 9 | 10 | {:ok, 11 | VotingResults.get_result_by_proposal_url(voting_method.id, proposal_url, organization_id)} 12 | rescue 13 | Ecto.NoResultsError -> {:error, message: "No matching result found"} 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/liquid_voting_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule LiquidVotingWeb.Router do 2 | use LiquidVotingWeb, :router 3 | 4 | pipeline :api do 5 | plug :accepts, ["json"] 6 | plug LiquidVotingWeb.Plugs.Context 7 | plug Geometrics.Plug.OpenTelemetry 8 | end 9 | 10 | scope "/" do 11 | pipe_through :api 12 | 13 | forward "/graphiql", Absinthe.Plug.GraphiQL, 14 | schema: LiquidVotingWeb.Schema.Schema, 15 | socket: LiquidVotingWeb.UserSocket, 16 | interface: :advanced 17 | 18 | forward "/", Absinthe.Plug, schema: LiquidVotingWeb.Schema.Schema 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/liquid_voting_web/schema/changeset_errors.ex: -------------------------------------------------------------------------------- 1 | defmodule LiquidVotingWeb.Schema.ChangesetErrors do 2 | @doc """ 3 | Traverses the changeset errors and returns a map of 4 | error messages. For example: 5 | 6 | %{start_date: ["can't be blank"], end_date: ["can't be blank"]} 7 | """ 8 | def error_details(changeset) do 9 | Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> 10 | Enum.reduce(opts, msg, fn {key, value}, acc -> 11 | String.replace(acc, "%{#{key}}", to_string(value)) 12 | end) 13 | end) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/liquid_voting_web/schema/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule LiquidVotingWeb.Schema.Schema do 2 | use Absinthe.Schema 3 | 4 | import_types(Absinthe.Type.Custom) 5 | import Absinthe.Resolution.Helpers, only: [dataloader: 1, dataloader: 3] 6 | 7 | alias LiquidVotingWeb.Resolvers 8 | alias LiquidVoting.{Voting, VotingMethods, VotingResults} 9 | 10 | query do 11 | @desc "Get a voting result by its voting method (optional) and proposal url" 12 | field :voting_result, :result do 13 | arg(:proposal_url, non_null(:string)) 14 | arg(:voting_method, :string) 15 | resolve(&Resolvers.VotingResults.result/3) 16 | end 17 | 18 | @desc "Get a list of participants" 19 | field :participants, list_of(:participant) do 20 | resolve(&Resolvers.Voting.participants/3) 21 | end 22 | 23 | @desc "Get a participant by its id" 24 | field :participant, :participant do 25 | arg(:id, non_null(:string)) 26 | resolve(&Resolvers.Voting.participant/3) 27 | end 28 | 29 | @desc "Get a list of votes, optionally filtered by proposal url and voting method" 30 | field :votes, list_of(:vote) do 31 | arg(:proposal_url, :string) 32 | arg(:voting_method, :string) 33 | resolve(&Resolvers.Voting.votes/3) 34 | end 35 | 36 | @desc "Get a vote by its id" 37 | field :vote, :vote do 38 | arg(:id, non_null(:string)) 39 | resolve(&Resolvers.Voting.vote/3) 40 | end 41 | 42 | @desc "Get a list of delegations" 43 | field :delegations, list_of(:delegation) do 44 | resolve(&Resolvers.Delegations.delegations/3) 45 | end 46 | 47 | @desc "Get a delegation by its id" 48 | field :delegation, :delegation do 49 | arg(:id, non_null(:string)) 50 | resolve(&Resolvers.Delegations.delegation/3) 51 | end 52 | end 53 | 54 | mutation do 55 | @desc "Create a participant" 56 | field :create_participant, :participant do 57 | arg(:name, non_null(:string)) 58 | arg(:email, non_null(:string)) 59 | resolve(&Resolvers.Voting.create_participant/3) 60 | end 61 | 62 | @desc "Create a vote for a proposal" 63 | field :create_vote, :vote do 64 | arg(:proposal_url, non_null(:string)) 65 | arg(:participant_id, :string) 66 | arg(:participant_email, :string) 67 | arg(:yes, non_null(:boolean)) 68 | arg(:voting_method, :string) 69 | resolve(&Resolvers.Voting.create_vote/3) 70 | end 71 | 72 | @desc "Delete a vote for a proposal" 73 | field :delete_vote, :vote do 74 | arg(:proposal_url, non_null(:string)) 75 | arg(:participant_id, :string) 76 | arg(:participant_email, :string) 77 | arg(:voting_method, :string) 78 | resolve(&Resolvers.Voting.delete_vote/3) 79 | end 80 | 81 | @desc "Create a delegation" 82 | field :create_delegation, :delegation do 83 | arg(:delegator_id, :string) 84 | arg(:delegate_id, :string) 85 | arg(:delegator_email, :string) 86 | arg(:delegate_email, :string) 87 | arg(:proposal_url, :string) 88 | arg(:voting_method, :string) 89 | resolve(&Resolvers.Delegations.create_delegation/3) 90 | end 91 | 92 | @desc "Delete a delegation" 93 | field :delete_delegation, :delegation do 94 | arg(:delegator_id, :string) 95 | arg(:delegate_id, :string) 96 | arg(:delegator_email, :string) 97 | arg(:delegate_email, :string) 98 | arg(:proposal_url, :string) 99 | arg(:voting_method, :string) 100 | resolve(&Resolvers.Delegations.delete_delegation/3) 101 | end 102 | end 103 | 104 | subscription do 105 | @desc "Subscribe to voting results changes for a proposal" 106 | field :voting_result_change, :result do 107 | arg(:proposal_url, non_null(:string)) 108 | arg(:voting_method, :string) 109 | 110 | config(fn args, _res -> 111 | {:ok, topic: args.proposal_url} 112 | end) 113 | end 114 | end 115 | 116 | object :participant do 117 | field :id, non_null(:string) 118 | field :name, :string 119 | field :email, non_null(:string) 120 | 121 | field :delegations_received, list_of(:delegation), 122 | resolve: 123 | dataloader(Voting, :delegations_received, 124 | args: %{scope: :participant, foreign_key: :delegate_id} 125 | ) 126 | end 127 | 128 | object :vote do 129 | field :id, non_null(:string) 130 | field :yes, non_null(:boolean) 131 | field :weight, non_null(:integer) 132 | field :proposal_url, non_null(:string) 133 | field :voting_method, non_null(:voting_method), resolve: dataloader(VotingMethods) 134 | field :participant, non_null(:participant), resolve: dataloader(Voting) 135 | 136 | field :voting_result, :result, 137 | resolve: fn vote, _, _ -> 138 | {:ok, 139 | VotingResults.get_result_by_proposal_url( 140 | vote.voting_method.id, 141 | vote.proposal_url, 142 | vote.organization_id 143 | )} 144 | end 145 | end 146 | 147 | object :delegation do 148 | field :id, non_null(:string) 149 | field :delegator, non_null(:participant), resolve: dataloader(Voting) 150 | field :delegate, non_null(:participant), resolve: dataloader(Voting) 151 | field :proposal_url, :string 152 | field :voting_method, :voting_method, resolve: dataloader(VotingMethods) 153 | 154 | field :voting_result, :result, 155 | resolve: fn delegation, _, _ -> 156 | if delegation.proposal_url do 157 | {:ok, 158 | VotingResults.get_result_by_proposal_url( 159 | delegation.voting_method.id, 160 | delegation.proposal_url, 161 | delegation.organization_id 162 | )} 163 | else 164 | {:ok, nil} 165 | end 166 | end 167 | end 168 | 169 | object :result do 170 | field :id, non_null(:string) 171 | field :in_favor, non_null(:integer) 172 | field :against, non_null(:integer) 173 | field :voting_method, non_null(:voting_method), resolve: dataloader(VotingMethods) 174 | field :proposal_url, non_null(:string) 175 | end 176 | 177 | object :voting_method do 178 | field :id, non_null(:string) 179 | field :name, non_null(:string) 180 | end 181 | 182 | def context(ctx) do 183 | source = Dataloader.Ecto.new(LiquidVoting.Repo) 184 | 185 | loader = 186 | Dataloader.new() 187 | |> Dataloader.add_source(Voting, source) 188 | |> Dataloader.add_source(Delegations, source) 189 | |> Dataloader.add_source(VotingMethods, source) 190 | 191 | Map.put(ctx, :loader, loader) 192 | end 193 | 194 | def plugins do 195 | [Absinthe.Middleware.Dataloader] ++ Absinthe.Plugin.defaults() 196 | end 197 | end 198 | -------------------------------------------------------------------------------- /lib/liquid_voting_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule LiquidVotingWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | @doc """ 7 | Translates an error message using gettext. 8 | """ 9 | def translate_error({msg, opts}) do 10 | # When using gettext, we typically pass the strings we want 11 | # to translate as a static argument: 12 | # 13 | # # Translate "is invalid" in the "errors" domain 14 | # dgettext("errors", "is invalid") 15 | # 16 | # # Translate the number of files with plural rules 17 | # dngettext("errors", "1 file", "%{count} files", count) 18 | # 19 | # Because the error messages we show in our forms and APIs 20 | # are defined inside Ecto, we need to translate them dynamically. 21 | # This requires us to call the Gettext module passing our gettext 22 | # backend as first argument. 23 | # 24 | # Note we use the "errors" domain, which means translations 25 | # should be written to the errors.po file. The :count option is 26 | # set by Ecto and indicates we should also apply plural rules. 27 | if count = opts[:count] do 28 | Gettext.dngettext(LiquidVotingWeb.Gettext, "errors", msg, msg, count, opts) 29 | else 30 | Gettext.dgettext(LiquidVotingWeb.Gettext, "errors", msg, opts) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/liquid_voting_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule LiquidVotingWeb.ErrorView do 2 | use LiquidVotingWeb, :view 3 | 4 | # If you want to customize a particular status code 5 | # for a certain format, you may uncomment below. 6 | # def render("500.json", _assigns) do 7 | # %{errors: %{detail: "Internal Server Error"}} 8 | # end 9 | 10 | # By default, Phoenix returns the status message from 11 | # the template name. For example, "404.json" becomes 12 | # "Not Found". 13 | def template_not_found(template, _assigns), 14 | do: %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} 15 | end 16 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :liquid_voting, 7 | version: "0.1.0", 8 | elixir: "~> 1.10", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | compilers: [:phoenix, :gettext] ++ Mix.compilers(), 11 | start_permanent: Mix.env() == :prod, 12 | aliases: aliases(), 13 | deps: deps() 14 | ] 15 | end 16 | 17 | # Configuration for the OTP application. 18 | # 19 | # Type `mix help compile.app` for more information. 20 | def application do 21 | [ 22 | mod: {LiquidVoting.Application, []}, 23 | extra_applications: [:logger, :runtime_tools] 24 | ] 25 | end 26 | 27 | # Specifies which paths to compile per environment. 28 | defp elixirc_paths(:test), do: ["lib", "test/support"] 29 | defp elixirc_paths(_), do: ["lib"] 30 | 31 | # Specifies your project dependencies. 32 | # 33 | # Type `mix help deps` for examples and options. 34 | defp deps do 35 | [ 36 | {:phoenix, "~> 1.5.0"}, 37 | {:phoenix_pubsub, "~> 2.0"}, 38 | {:phoenix_ecto, "~> 4.0"}, 39 | {:ecto_sql, "~> 3.1"}, 40 | {:postgrex, "~> 0.15.0"}, 41 | {:gettext, "~> 0.11"}, 42 | {:jason, "~> 1.0"}, 43 | {:plug_cowboy, "~> 2.0"}, 44 | {:corsica, "~> 1.0"}, 45 | {:absinthe, "~> 1.5"}, 46 | {:absinthe_plug, "~> 1.5"}, 47 | {:absinthe_phoenix, "~> 2.0"}, 48 | {:dataloader, "~> 1.0.6"}, 49 | {:credo, "~> 1.5.0", only: [:dev, :test], runtime: false}, 50 | {:telemetry, "~> 0.4.0"}, 51 | {:opentelemetry, "~> 0.6.0", override: true}, 52 | {:opentelemetry_api, "~> 0.6.0", override: true}, 53 | {:opentelemetry_honeycomb, "~> 0.5.0-rc.1"}, 54 | {:opentelemetry_phoenix, "~> 0.2.0", override: true}, 55 | {:geometrics, github: "geometerio/geometrics", branch: "main"}, 56 | {:hackney, ">= 1.11.0"}, 57 | {:poison, ">= 1.5.0"}, 58 | {:ex_machina, "~> 2.3", only: :test}, 59 | {:ecto_fields, "~> 1.3.0"} 60 | ] 61 | end 62 | 63 | # Aliases are shortcuts or tasks specific to the current project. 64 | # For example, to create, migrate and run the seeds file at once: 65 | # 66 | # $ mix ecto.setup 67 | # 68 | # See the documentation for `Mix` for more info on aliases. 69 | defp aliases do 70 | [ 71 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 72 | "ecto.reset": ["ecto.drop", "ecto.setup"], 73 | test: ["ecto.migrate", "test"] 74 | ] 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## `msgid`s in this file come from POT (.pot) files. 2 | ## 3 | ## Do not add, change, or remove `msgid`s manually here as 4 | ## they're tied to the ones in the corresponding POT file 5 | ## (with the same domain). 6 | ## 7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge` 8 | ## to merge POT files into PO files. 9 | msgid "" 10 | msgstr "" 11 | "Language: en\n" 12 | 13 | ## From Ecto.Changeset.cast/4 14 | msgid "can't be blank" 15 | msgstr "" 16 | 17 | ## From Ecto.Changeset.unique_constraint/3 18 | msgid "has already been taken" 19 | msgstr "" 20 | 21 | ## From Ecto.Changeset.put_change/3 22 | msgid "is invalid" 23 | msgstr "" 24 | 25 | ## From Ecto.Changeset.validate_acceptance/3 26 | msgid "must be accepted" 27 | msgstr "" 28 | 29 | ## From Ecto.Changeset.validate_format/3 30 | msgid "has invalid format" 31 | msgstr "" 32 | 33 | ## From Ecto.Changeset.validate_subset/3 34 | msgid "has an invalid entry" 35 | msgstr "" 36 | 37 | ## From Ecto.Changeset.validate_exclusion/3 38 | msgid "is reserved" 39 | msgstr "" 40 | 41 | ## From Ecto.Changeset.validate_confirmation/3 42 | msgid "does not match confirmation" 43 | msgstr "" 44 | 45 | ## From Ecto.Changeset.no_assoc_constraint/3 46 | msgid "is still associated with this entry" 47 | msgstr "" 48 | 49 | msgid "are still associated with this entry" 50 | msgstr "" 51 | 52 | ## From Ecto.Changeset.validate_length/3 53 | msgid "should be %{count} character(s)" 54 | msgid_plural "should be %{count} character(s)" 55 | msgstr[0] "" 56 | msgstr[1] "" 57 | 58 | msgid "should have %{count} item(s)" 59 | msgid_plural "should have %{count} item(s)" 60 | msgstr[0] "" 61 | msgstr[1] "" 62 | 63 | msgid "should be at least %{count} character(s)" 64 | msgid_plural "should be at least %{count} character(s)" 65 | msgstr[0] "" 66 | msgstr[1] "" 67 | 68 | msgid "should have at least %{count} item(s)" 69 | msgid_plural "should have at least %{count} item(s)" 70 | msgstr[0] "" 71 | msgstr[1] "" 72 | 73 | msgid "should be at most %{count} character(s)" 74 | msgid_plural "should be at most %{count} character(s)" 75 | msgstr[0] "" 76 | msgstr[1] "" 77 | 78 | msgid "should have at most %{count} item(s)" 79 | msgid_plural "should have at most %{count} item(s)" 80 | msgstr[0] "" 81 | msgstr[1] "" 82 | 83 | ## From Ecto.Changeset.validate_number/3 84 | msgid "must be less than %{number}" 85 | msgstr "" 86 | 87 | msgid "must be greater than %{number}" 88 | msgstr "" 89 | 90 | msgid "must be less than or equal to %{number}" 91 | msgstr "" 92 | 93 | msgid "must be greater than or equal to %{number}" 94 | msgstr "" 95 | 96 | msgid "must be equal to %{number}" 97 | msgstr "" 98 | -------------------------------------------------------------------------------- /priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This is a PO Template file. 2 | ## 3 | ## `msgid`s here are often extracted from source code. 4 | ## Add new translations manually only if they're dynamic 5 | ## translations that can't be statically extracted. 6 | ## 7 | ## Run `mix gettext.extract` to bring this file up to 8 | ## date. Leave `msgstr`s empty as changing them here has no 9 | ## effect: edit them in PO (`.po`) files instead. 10 | 11 | ## From Ecto.Changeset.cast/4 12 | msgid "can't be blank" 13 | msgstr "" 14 | 15 | ## From Ecto.Changeset.unique_constraint/3 16 | msgid "has already been taken" 17 | msgstr "" 18 | 19 | ## From Ecto.Changeset.put_change/3 20 | msgid "is invalid" 21 | msgstr "" 22 | 23 | ## From Ecto.Changeset.validate_acceptance/3 24 | msgid "must be accepted" 25 | msgstr "" 26 | 27 | ## From Ecto.Changeset.validate_format/3 28 | msgid "has invalid format" 29 | msgstr "" 30 | 31 | ## From Ecto.Changeset.validate_subset/3 32 | msgid "has an invalid entry" 33 | msgstr "" 34 | 35 | ## From Ecto.Changeset.validate_exclusion/3 36 | msgid "is reserved" 37 | msgstr "" 38 | 39 | ## From Ecto.Changeset.validate_confirmation/3 40 | msgid "does not match confirmation" 41 | msgstr "" 42 | 43 | ## From Ecto.Changeset.no_assoc_constraint/3 44 | msgid "is still associated with this entry" 45 | msgstr "" 46 | 47 | msgid "are still associated with this entry" 48 | msgstr "" 49 | 50 | ## From Ecto.Changeset.validate_length/3 51 | msgid "should be %{count} character(s)" 52 | msgid_plural "should be %{count} character(s)" 53 | msgstr[0] "" 54 | msgstr[1] "" 55 | 56 | msgid "should have %{count} item(s)" 57 | msgid_plural "should have %{count} item(s)" 58 | msgstr[0] "" 59 | msgstr[1] "" 60 | 61 | msgid "should be at least %{count} character(s)" 62 | msgid_plural "should be at least %{count} character(s)" 63 | msgstr[0] "" 64 | msgstr[1] "" 65 | 66 | msgid "should have at least %{count} item(s)" 67 | msgid_plural "should have at least %{count} item(s)" 68 | msgstr[0] "" 69 | msgstr[1] "" 70 | 71 | msgid "should be at most %{count} character(s)" 72 | msgid_plural "should be at most %{count} character(s)" 73 | msgstr[0] "" 74 | msgstr[1] "" 75 | 76 | msgid "should have at most %{count} item(s)" 77 | msgid_plural "should have at most %{count} item(s)" 78 | msgstr[0] "" 79 | msgstr[1] "" 80 | 81 | ## From Ecto.Changeset.validate_number/3 82 | msgid "must be less than %{number}" 83 | msgstr "" 84 | 85 | msgid "must be greater than %{number}" 86 | msgstr "" 87 | 88 | msgid "must be less than or equal to %{number}" 89 | msgstr "" 90 | 91 | msgid "must be greater than or equal to %{number}" 92 | msgstr "" 93 | 94 | msgid "must be equal to %{number}" 95 | msgstr "" 96 | -------------------------------------------------------------------------------- /priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200710123927_create_participants.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.Repo.Migrations.CreateParticipants do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:participants) do 6 | add :name, :string 7 | add :email, :string 8 | add :organization_id, :uuid, null: false 9 | 10 | timestamps() 11 | end 12 | 13 | create index(:participants, [:organization_id]) 14 | 15 | create unique_index(:participants, [:organization_id, :email], 16 | name: :uniq_index_organization_id_participant_email 17 | ) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200710132150_create_votes.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.Repo.Migrations.CreateVotes do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:votes) do 6 | add :yes, :boolean, default: false, null: false 7 | add :weight, :integer, default: 1 8 | add :participant_id, references(:participants, on_delete: :nothing) 9 | add :proposal_url, :text, default: false, null: false 10 | add :organization_id, :uuid, null: false 11 | 12 | timestamps() 13 | end 14 | 15 | create index(:votes, [:organization_id, :participant_id]) 16 | create index(:votes, [:organization_id, :proposal_url]) 17 | create index(:votes, [:organization_id]) 18 | 19 | create unique_index(:votes, [:organization_id, :participant_id, :proposal_url], 20 | name: :uniq_index_org_vote_participant_proposal 21 | ) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200710153620_create_delegations.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.Repo.Migrations.CreateDelegations do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:delegations) do 6 | add :delegator_id, references(:participants, on_delete: :delete_all) 7 | add :delegate_id, references(:participants, on_delete: :delete_all) 8 | add :proposal_url, :text 9 | add :organization_id, :uuid, null: false 10 | 11 | timestamps() 12 | end 13 | 14 | create index(:delegations, [:organization_id, :delegator_id], 15 | name: :index_delegation_delegator_org 16 | ) 17 | 18 | create index(:delegations, [:organization_id, :delegate_id], 19 | name: :index_delegation_delegate_org 20 | ) 21 | 22 | create index(:delegations, [:organization_id, :proposal_url], 23 | name: :index_delegation_proposal_org 24 | ) 25 | 26 | create index(:delegations, [:organization_id]) 27 | 28 | create unique_index(:delegations, [:organization_id, :delegator_id, :delegate_id], 29 | name: :uniq_index_org_delegator_delegate 30 | ) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200710182224_create_results.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.Repo.Migrations.CreateResults do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:results) do 6 | add :in_favor, :integer, default: 0 7 | add :against, :integer, default: 0 8 | add :proposal_url, :string, default: false, null: false 9 | add :organization_id, :uuid, null: false 10 | 11 | timestamps() 12 | end 13 | 14 | create index(:results, [:organization_id]) 15 | 16 | create unique_index(:results, [:organization_id, :proposal_url], 17 | name: :uniq_index_organization_id_proposal_url 18 | ) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200821125447_add_delegations_index_org_delegator.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.Repo.Migrations.AddDelegationsIndexOrgDelegator do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create index(:delegations, [:organization_id, :delegator_id], 6 | name: :index_delegation_org_delegator 7 | ) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200910111541_drop_delegation_uniq_index_org_delegator_delegate.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.Repo.Migrations.DropDelegationUniqIndexOrgDelegatorDelegate do 2 | use Ecto.Migration 3 | 4 | def change do 5 | drop unique_index(:delegations, [:organization_id, :delegator_id, :delegate_id], 6 | name: :uniq_index_org_delegator_delegate 7 | ) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200910131922_create_delegations_uniq_index_org_delegator_delegate_proposal.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.Repo.Migrations.CreateDelegationsUniqIndexOrgDelegatorDelegateProposal do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create unique_index( 6 | :delegations, 7 | [:organization_id, :delegator_id, :delegate_id, :proposal_url], 8 | name: :uniq_index_org_delegator_delegate_proposal 9 | ) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210329184924_add_voting_methods_table.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.Repo.Migrations.AddVotingMethodsTable do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:voting_methods) do 6 | add :voting_method, :string 7 | add :organization_id, :uuid, null: false 8 | 9 | timestamps() 10 | end 11 | 12 | create unique_index(:voting_methods, [:organization_id, :voting_method], 13 | name: :uniq_index_org_voting_method 14 | ) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210330103112_vote_belongs_to_voting_method.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.Repo.Migrations.VoteBelongsToVotingMethod do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:votes) do 6 | add :voting_method_id, references(:voting_methods, on_delete: :nothing) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210330103629_result_belongs_to_voting_method.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.Repo.Migrations.ResultBelongsToVotingMethod do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:results) do 6 | add :voting_method_id, references(:voting_methods, on_delete: :nothing) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210331104227_drop_votes_uniq_index_org_participant_proposal.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.Repo.Migrations.DropVotesUniqIndexOrgParticipantProposal do 2 | use Ecto.Migration 3 | 4 | def change do 5 | drop unique_index(:votes, [:organization_id, :participant_id, :proposal_url], 6 | name: :uniq_index_org_vote_participant_proposal 7 | ) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210331104318_create_votes_uniq_index_org_participant_proposal_voting_method.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.Repo.Migrations.CreateVotesUniqIndexOrgParticipantProposalVotingMethod do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create unique_index( 6 | :votes, 7 | [:organization_id, :participant_id, :proposal_url, :voting_method_id], 8 | name: :uniq_index_org_participant_proposal_voting_method 9 | ) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210331170739_rename_voting_methods_voting_method_to_name.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.Repo.Migrations.RenameVotingMethodsVotingMethodToName do 2 | use Ecto.Migration 3 | 4 | def change do 5 | rename table(:voting_methods), :voting_method, to: :name 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210331171214_drop_voting_methods_uniq_index_org_voting_method.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.Repo.Migrations.DropVotingMethodsUniqIndexOrgVotingMethod do 2 | use Ecto.Migration 3 | 4 | def change do 5 | drop unique_index(:voting_methods, [:organization_id, :voting_method], 6 | name: :uniq_index_org_voting_method 7 | ) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210331171316_create_voting_methods_uniq_index_org_name.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.Repo.Migrations.CreateVotingMethodsUniqIndexOrgName do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create unique_index(:voting_methods, [:organization_id, :name], name: :uniq_index_org_name) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210402102314_drop_results_uniq_index_organization_id_proposal_url.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.Repo.Migrations.DropResultsUniqIndexOrganizationIdProposalUrl do 2 | use Ecto.Migration 3 | 4 | def change do 5 | drop unique_index(:results, [:organization_id, :proposal_url], 6 | name: :uniq_index_organization_id_proposal_url 7 | ) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210402102530_create_results_uniq_index_org_proposal_voting_method.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.Repo.Migrations.CreateResultsUniqIndexOrgProposalVotingMethod do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create unique_index(:results, [:organization_id, :proposal_url, :voting_method_id], 6 | name: :uniq_index_org_proposal_voting_method 7 | ) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210405112229_add_voting_methods_default_value_for_name.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.Repo.Migrations.AddVotingMethodsDefaultValueForName do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:voting_methods) do 6 | modify :name, :string, default: "default" 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210405132557_delegation_belongs_to_voting_method.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.Repo.Migrations.DelegationBelongsToVotingMethod do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:delegations) do 6 | add :voting_method_id, references(:voting_methods, on_delete: :nothing) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210406174454_drop_delegations_uniq_index_org_delegator_delegate_proposal.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.Repo.Migrations.DropDelegationsUniqIndexOrgDelegatorDelegateProposal do 2 | use Ecto.Migration 3 | 4 | def change do 5 | drop unique_index( 6 | :delegations, 7 | [:organization_id, :delegator_id, :delegate_id, :proposal_url], 8 | name: :uniq_index_org_delegator_delegate_proposal 9 | ) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210406174537_create_delegations_uniq_index_org_delegator_delegate_proposal_voting_method.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.Repo.Migrations.CreateDelegationsUniqIndexOrgDelegatorDelegateProposalVotingMethod do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create unique_index( 6 | :delegations, 7 | [:organization_id, :delegator_id, :delegate_id, :proposal_url, :voting_method_id], 8 | name: :uniq_index_org_delegator_delegate_proposal_voting_method 9 | ) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # alias LiquidVoting.Repo 2 | # alias LiquidVoting.Voting 3 | # alias LiquidVoting.VotingResults 4 | 5 | # participant = Voting.create_participant!(%{name: "Lucia Coelho", email: "lucia@coelho.com"}) 6 | # delegator = Voting.create_participant!(%{name: "Zubin Kurozawa", email: "zubin@kurozawa.com"}) 7 | # delegator2 = Voting.create_participant!(%{name: "Louie Louie", email: "louie@louie.com"}) 8 | 9 | # Voting.create_delegation!(%{ 10 | # delegator_id: delegator.id, 11 | # delegate_id: participant.id 12 | # }) 13 | 14 | # Voting.create_delegation!(%{ 15 | # delegator_id: delegator2.id, 16 | # delegate_id: participant.id 17 | # }) 18 | 19 | # participant = Repo.preload(participant, :delegations_received) 20 | # proposal_url = "https://github.com/user/repo/pulls/15" 21 | 22 | # vote = 23 | # Voting.create_vote!(%{ 24 | # yes: true, 25 | # proposal_url: proposal_url, 26 | # participant_id: participant.id 27 | # }) 28 | 29 | # participant2 = Voting.create_participant!(%{name: "Francine Dunlop", email: "francine@dunlop.com"}) 30 | 31 | # vote = 32 | # Voting.create_vote!(%{ 33 | # yes: false, 34 | # proposal_url: proposal_url, 35 | # participant_id: participant2.id 36 | # }) 37 | 38 | # VotingResults.calculate_result!(proposal_url) 39 | # |> IO.inspect 40 | -------------------------------------------------------------------------------- /rel/env.bat.eex: -------------------------------------------------------------------------------- 1 | @echo off 2 | rem Set the release to work across nodes. If using the long name format like 3 | rem the one below (my_app@127.0.0.1), you need to also uncomment the 4 | rem RELEASE_DISTRIBUTION variable below. 5 | rem set RELEASE_DISTRIBUTION=name 6 | rem set RELEASE_NODE=<%= @release.name %>@127.0.0.1 7 | -------------------------------------------------------------------------------- /rel/env.sh.eex: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Sets and enables heart (recommended only in daemon mode) 4 | # case $RELEASE_COMMAND in 5 | # daemon*) 6 | # HEART_COMMAND="$RELEASE_ROOT/bin/$RELEASE_NAME $RELEASE_COMMAND" 7 | # export HEART_COMMAND 8 | # export ELIXIR_ERL_OPTIONS="-heart" 9 | # ;; 10 | # *) 11 | # ;; 12 | # esac 13 | 14 | # Set the release to work across nodes. If using the long name format like 15 | # the one below (my_app@127.0.0.1), you need to also uncomment the 16 | # RELEASE_DISTRIBUTION variable below. 17 | # export RELEASE_DISTRIBUTION=name 18 | # export RELEASE_NODE=<%= @release.name %>@127.0.0.1 19 | -------------------------------------------------------------------------------- /rel/vm.args.eex: -------------------------------------------------------------------------------- 1 | ## Customize flags given to the VM: http://erlang.org/doc/man/erl.html 2 | ## -mode/-name/-sname/-setcookie are configured via env vars, do not set them here 3 | 4 | ## Number of dirty schedulers doing IO work (file, sockets, etc) 5 | ##+SDio 5 6 | 7 | ## Increase number of concurrent ports/sockets 8 | ##+Q 65536 9 | 10 | ## Tweak GC to run more often 11 | ##-env ERL_FULLSWEEP_AFTER 10 12 | -------------------------------------------------------------------------------- /scripts/hello-world-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | /opt/app/bin/liquid_voting eval "LiquidVoting.Release.migrate" 5 | 6 | exec "$@" 7 | -------------------------------------------------------------------------------- /test/liquid_voting/delegations_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.DelegationsTest do 2 | use LiquidVoting.DataCase 3 | import LiquidVoting.Factory 4 | 5 | alias LiquidVoting.Delegations 6 | alias LiquidVoting.Delegations.Delegation 7 | 8 | describe "delegations" do 9 | setup do 10 | delegator = insert(:participant) 11 | delegate = insert(:participant) 12 | another_delegate = insert(:participant) 13 | organization_id = Ecto.UUID.generate() 14 | proposal_url = "https://www.someorg/proposalX" 15 | another_proposal_url = "https://someorg/another-proposal" 16 | 17 | [ 18 | proposal_url: proposal_url, 19 | valid_attrs: %{ 20 | delegator_id: delegator.id, 21 | delegate_id: delegate.id, 22 | organization_id: organization_id 23 | }, 24 | update_attrs: %{ 25 | delegator_id: delegator.id, 26 | delegate_id: another_delegate.id, 27 | organization_id: organization_id 28 | }, 29 | invalid_attrs: %{ 30 | delegator_id: delegator.id, 31 | delegate_id: nil, 32 | organization_id: nil 33 | }, 34 | valid_proposal_specific_attrs: %{ 35 | delegator_id: delegator.id, 36 | delegate_id: delegate.id, 37 | organization_id: organization_id, 38 | proposal_url: proposal_url 39 | }, 40 | another_proposal_url: another_proposal_url 41 | ] 42 | end 43 | 44 | test "list_delegations/1 returns all delegations for an organization_id" do 45 | delegation = insert(:delegation) 46 | 47 | assert Delegations.list_delegations(delegation.organization_id) == [delegation] 48 | end 49 | 50 | test "get_delegation!/2 returns the delegation with given id and organization_id" do 51 | delegation = insert(:delegation) 52 | 53 | assert Delegations.get_delegation!(delegation.id, delegation.organization_id) == 54 | delegation 55 | end 56 | 57 | test "get_delegation!/3 returns a global delegation with given emails and organization_id" do 58 | delegation = insert(:delegation) 59 | 60 | result = 61 | Delegations.get_delegation!( 62 | delegation.delegator.email, 63 | delegation.delegate.email, 64 | delegation.organization_id 65 | ) 66 | 67 | assert result.id == delegation.id 68 | end 69 | 70 | test "get_delegation!/3 returns a proposal-specific delegation with given emails, voting_method_name, proposal_url and organization_id" do 71 | delegation = insert(:delegation_for_proposal) 72 | 73 | result = 74 | Delegations.get_delegation!( 75 | delegation.delegator.email, 76 | delegation.delegate.email, 77 | delegation.voting_method.name, 78 | delegation.proposal_url, 79 | delegation.organization_id 80 | ) 81 | 82 | assert result.id == delegation.id 83 | end 84 | 85 | test "get_delegation!/3 returns returns error if a global delegation does not exist", 86 | context do 87 | assert_raise Ecto.NoResultsError, fn -> 88 | Delegations.get_delegation!( 89 | "random@person.com", 90 | "random2@person.com", 91 | context[:valid_attrs][:organization_id] 92 | ) 93 | end 94 | end 95 | 96 | test "get_delegation!/5 returns returns error if a proposal-specific delegation does not exist", 97 | context do 98 | assert_raise Ecto.NoResultsError, fn -> 99 | Delegations.get_delegation!( 100 | "random@person.com", 101 | "random2@person.com", 102 | "no_such_voting_method", 103 | "https://proposals.com/random_proposal", 104 | context[:valid_attrs][:organization_id] 105 | ) 106 | end 107 | end 108 | 109 | test "create_delegation/1 with valid data creates a delegation", context do 110 | assert {:ok, %Delegation{}} = Delegations.create_delegation(context[:valid_attrs]) 111 | end 112 | 113 | test "create_delegation/1 with invalid data returns error changeset", context do 114 | assert {:error, %Ecto.Changeset{}} = Delegations.create_delegation(context[:invalid_attrs]) 115 | end 116 | 117 | test "create_delegation/1 with same participant as delegator and delegate returns error changeset" do 118 | participant = insert(:participant) 119 | 120 | args = %{ 121 | delegator_id: participant.id, 122 | delegate_id: participant.id, 123 | organization_id: Ecto.UUID.generate() 124 | } 125 | 126 | assert {:error, %Ecto.Changeset{}} = Delegations.create_delegation(args) 127 | end 128 | 129 | test "create_delegation/1 with proposal url creates a delegation", context do 130 | # Test long urls while at it 131 | proposal_url = """ 132 | https://www.bigassstring.com/search?ei=WdznXfzyIoeT1fAP79yWqAc&q=chrome+extension+popup+js+xhr+onload+document.body&oq=chrome+extension+popup+js+xhr+onload+document.body&gs_l=psy-ab.3...309222.313422..314027...0.0..1.201.1696.5j9j1....2..0....1..gws-wiz.2OvPoKSwZ_I&ved=0ahUKEwi8g5fQspzmAhWHSRUIHW-uBXUQ4dUDCAs&uact=5" 133 | """ 134 | 135 | args = Map.merge(context[:valid_attrs], %{proposal_url: proposal_url}) 136 | assert {:ok, %Delegation{}} = Delegations.create_delegation(args) 137 | end 138 | 139 | test "create_delegation/1 with duplicate proposal-specific data returns the original delegation" do 140 | original_delegation = insert(:delegation_for_proposal) 141 | 142 | args = %{ 143 | delegator_id: original_delegation.delegator_id, 144 | delegate_id: original_delegation.delegate_id, 145 | organization_id: original_delegation.organization_id, 146 | voting_method: original_delegation.voting_method.name, 147 | proposal_url: original_delegation.proposal_url 148 | } 149 | 150 | assert {:ok, %Delegation{} = delegation} = Delegations.create_delegation(args) 151 | 152 | assert original_delegation.id == delegation.id 153 | assert original_delegation.organization_id == delegation.organization_id 154 | assert original_delegation.delegate_id == delegation.delegate_id 155 | assert original_delegation.delegator_id == delegation.delegator_id 156 | assert original_delegation.voting_method_id == delegation.voting_method_id 157 | assert original_delegation.proposal_url == delegation.proposal_url 158 | end 159 | 160 | test "create_delegation/1 creates second different proposal-specific delegation for same delegator/delegate pair", 161 | context do 162 | original_delegation = insert(:delegation, proposal_url: context[:proposal_url]) 163 | 164 | args = %{ 165 | delegator_id: original_delegation.delegator_id, 166 | delegate_id: original_delegation.delegate_id, 167 | organization_id: original_delegation.organization_id, 168 | proposal_url: context[:another_proposal_url] 169 | } 170 | 171 | assert {:ok, %Delegation{}} = Delegations.create_delegation(args) 172 | end 173 | 174 | test "upsert_delegation/1 with valid proposal_specific delegation data creates a delegation", 175 | context do 176 | assert {:ok, %Delegation{}} = 177 | Delegations.upsert_delegation(context[:valid_proposal_specific_attrs]) 178 | end 179 | 180 | test "upsert_delegation/1 with valid global delegation data creates a delegation", context do 181 | assert {:ok, %Delegation{}} = Delegations.upsert_delegation(context[:valid_attrs]) 182 | end 183 | 184 | test "upsert_delegation/1 with duplicate delegator and proposal_url updates the respective delegation", 185 | context do 186 | original_delegation = insert(:delegation, proposal_url: context[:proposal_url]) 187 | new_delegate = insert(:participant) 188 | 189 | args = %{ 190 | delegator_id: original_delegation.delegator_id, 191 | delegate_id: new_delegate.id, 192 | organization_id: original_delegation.organization_id, 193 | proposal_url: original_delegation.proposal_url 194 | } 195 | 196 | assert {:ok, %Delegation{} = modified_delegation} = Delegations.upsert_delegation(args) 197 | assert original_delegation.organization_id == modified_delegation.organization_id 198 | assert original_delegation.delegate_id != modified_delegation.delegate_id 199 | assert original_delegation.delegator_id == modified_delegation.delegator_id 200 | end 201 | 202 | test "upsert_delegation/1 for global delegation with same delegator updates the respective delegation" do 203 | original_delegation = insert(:delegation) 204 | new_delegate = insert(:participant) 205 | 206 | args = %{ 207 | delegator_id: original_delegation.delegator_id, 208 | delegate_id: new_delegate.id, 209 | organization_id: original_delegation.organization_id 210 | } 211 | 212 | assert {:ok, %Delegation{} = modified_delegation} = Delegations.upsert_delegation(args) 213 | assert original_delegation.organization_id == modified_delegation.organization_id 214 | assert original_delegation.delegate_id != modified_delegation.delegate_id 215 | assert original_delegation.delegator_id == modified_delegation.delegator_id 216 | end 217 | 218 | test "upsert_delegation/1 with duplicate global returns the original delegation" do 219 | original_delegation = insert(:delegation) 220 | 221 | args = %{ 222 | delegator_id: original_delegation.delegator_id, 223 | delegate_id: original_delegation.delegate_id, 224 | organization_id: original_delegation.organization_id 225 | } 226 | 227 | assert {:ok, %Delegation{} = delegation} = Delegations.upsert_delegation(args) 228 | assert delegation.id == original_delegation.id 229 | end 230 | 231 | test "upsert_delegation/1 with proposal-specifc data returns error if global delegation for same delegator/delegate pair exists", 232 | context do 233 | original_delegation = insert(:delegation) 234 | 235 | args = %{ 236 | delegator_id: original_delegation.delegator_id, 237 | delegate_id: original_delegation.delegate_id, 238 | organization_id: original_delegation.organization_id, 239 | proposal_url: context[:proposal_url] 240 | } 241 | 242 | {:error, %{details: details, message: message}} = Delegations.upsert_delegation(args) 243 | assert details == "A global delegation for the same participants already exists." 244 | assert message == "Could not create delegation." 245 | end 246 | 247 | test "upsert_delegation/1 with global delegation deletes an existing proposal-specific delegation for same delegator/delegate pair", 248 | context do 249 | original_delegation = insert(:delegation, proposal_url: context[:proposal_url]) 250 | 251 | args = %{ 252 | delegator_id: original_delegation.delegator_id, 253 | delegate_id: original_delegation.delegate_id, 254 | organization_id: original_delegation.organization_id 255 | } 256 | 257 | assert {:ok, %Delegation{}} = Delegations.upsert_delegation(args) 258 | 259 | assert_raise Ecto.NoResultsError, fn -> 260 | Delegations.get_delegation!(original_delegation.id, original_delegation.organization_id) 261 | end 262 | end 263 | 264 | test "upsert_delegation/1 with proposal delegation returns error if conflicting vote exists" do 265 | vote = insert(:vote) 266 | delegate = insert(:participant, organization_id: vote.organization_id) 267 | 268 | args = %{ 269 | delegator_id: vote.participant_id, 270 | delegate_id: delegate.id, 271 | voting_method_id: vote.voting_method_id, 272 | proposal_url: vote.proposal_url, 273 | organization_id: vote.organization_id 274 | } 275 | 276 | {:error, [message: message, details: details]} = Delegations.upsert_delegation(args) 277 | assert message == "Could not create delegation." 278 | assert details == "Delegator has already voted on this proposal." 279 | end 280 | 281 | test "upsert_delegation/1 with proposal delegation does not conflict with vote with different organization_id" do 282 | vote = insert(:vote) 283 | delegate = insert(:participant, organization_id: vote.organization_id) 284 | 285 | args = %{ 286 | delegator_id: vote.participant_id, 287 | delegate_id: delegate.id, 288 | proposal_url: vote.proposal_url, 289 | organization_id: Ecto.UUID.generate() 290 | } 291 | 292 | assert {:ok, %Delegation{}} = Delegations.upsert_delegation(args) 293 | end 294 | 295 | test "upsert_delegation/1 creates global delegation despite delegator having voted" do 296 | vote = insert(:vote) 297 | delegate = insert(:participant, organization_id: vote.organization_id) 298 | 299 | args = %{ 300 | delegator_id: vote.participant_id, 301 | delegate_id: delegate.id, 302 | organization_id: vote.organization_id 303 | } 304 | 305 | assert {:ok, %Delegation{}} = Delegations.upsert_delegation(args) 306 | end 307 | 308 | test "update_delegation/2 with valid data updates the delegation", context do 309 | delegation = insert(:delegation) 310 | 311 | assert {:ok, %Delegation{}} = 312 | Delegations.update_delegation(delegation, context[:update_attrs]) 313 | end 314 | 315 | test "delete_delegation/1 deletes the delegation" do 316 | delegation = insert(:delegation) 317 | assert {:ok, %Delegation{}} = Delegations.delete_delegation(delegation) 318 | 319 | assert_raise Ecto.NoResultsError, fn -> 320 | Delegations.get_delegation!(delegation.id, delegation.organization_id) 321 | end 322 | end 323 | 324 | test "change_delegation/1 returns a delegation changeset" do 325 | delegation = insert(:delegation) 326 | assert %Ecto.Changeset{} = Delegations.change_delegation(delegation) 327 | end 328 | end 329 | end 330 | -------------------------------------------------------------------------------- /test/liquid_voting/participants_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.ParticipantsTest do 2 | use LiquidVoting.DataCase 3 | import LiquidVoting.Factory 4 | 5 | alias LiquidVoting.Voting 6 | alias LiquidVoting.Voting.Participant 7 | 8 | describe "participants" do 9 | @valid_attrs %{ 10 | name: "some name", 11 | email: "some@email.com", 12 | organization_id: Ecto.UUID.generate() 13 | } 14 | @update_attrs %{ 15 | name: "some updated name", 16 | email: "another@email.com", 17 | organization_id: Ecto.UUID.generate() 18 | } 19 | @invalid_attrs %{email: nil, organization_id: nil} 20 | 21 | test "list_participants/1 returns all participants for an organization_id" do 22 | participant = insert(:participant) 23 | assert Voting.list_participants(participant.organization_id) == [participant] 24 | end 25 | 26 | test "get_participant!/2 returns the participant with given id and organization_id" do 27 | participant = insert(:participant) 28 | 29 | assert Voting.get_participant!(participant.id, participant.organization_id) == 30 | participant 31 | end 32 | 33 | test "get_participant_by_email/2 returns the participant with given email and organization_id" do 34 | participant = insert(:participant) 35 | 36 | assert Voting.get_participant_by_email(participant.email, participant.organization_id) == 37 | participant 38 | end 39 | 40 | test "get_participant_by_email/2 returns nil when a participant is not found" do 41 | assert Voting.get_participant_by_email( 42 | "non@participant.com", 43 | @valid_attrs[:organization_id] 44 | ) == nil 45 | end 46 | 47 | test "get_participant_by_email!/2 returns the participant with given email and organization_id" do 48 | participant = insert(:participant) 49 | 50 | assert Voting.get_participant_by_email!(participant.email, participant.organization_id) == 51 | participant 52 | end 53 | 54 | test "get_participant_by_email!/2 raises Ecto.NoResultsError when participant is not found" do 55 | assert_raise Ecto.NoResultsError, fn -> 56 | Voting.get_participant_by_email!("non@participant.com", @valid_attrs[:organization_id]) 57 | end 58 | end 59 | 60 | test "create_participant/1 with valid data creates a participant" do 61 | assert {:ok, %Participant{} = participant} = Voting.create_participant(@valid_attrs) 62 | assert participant.email == @valid_attrs[:email] 63 | assert participant.name == @valid_attrs[:name] 64 | end 65 | 66 | test "create_participant/1 with valid data creates an id" do 67 | assert {:ok, %Participant{} = participant} = Voting.create_participant(@valid_attrs) 68 | assert {:ok, _uuid_bitstring} = Ecto.UUID.dump(participant.id) 69 | end 70 | 71 | test "create_participant/1 with missing data returns error changeset" do 72 | assert {:error, %Ecto.Changeset{}} = Voting.create_participant(@invalid_attrs) 73 | end 74 | 75 | test "create_participant/1 with invalid email returns error changeset" do 76 | args = %{email: "invalid", name: "some name"} 77 | assert {:error, %Ecto.Changeset{}} = Voting.create_participant(args) 78 | end 79 | 80 | test "create_participant/1 with duplicate data returns error changeset" do 81 | Voting.create_participant(@valid_attrs) 82 | assert {:error, %Ecto.Changeset{}} = Voting.create_participant(@valid_attrs) 83 | end 84 | 85 | test "upsert_participant/2 with new valid data creates a participant" do 86 | assert {:ok, %Participant{} = participant} = Voting.upsert_participant(@valid_attrs) 87 | assert participant.email == @valid_attrs[:email] 88 | assert participant.name == @valid_attrs[:name] 89 | end 90 | 91 | test "upsert_participant/2 with existing valid data fetches matching participant" do 92 | insert(:participant, email: @valid_attrs[:email]) 93 | assert {:ok, %Participant{} = participant} = Voting.upsert_participant(@valid_attrs) 94 | assert participant.email == @valid_attrs[:email] 95 | assert participant.name == @valid_attrs[:name] 96 | end 97 | 98 | test "update_participant/2 with valid data updates the participant" do 99 | participant = insert(:participant) 100 | 101 | assert {:ok, %Participant{} = participant} = 102 | Voting.update_participant(participant, @update_attrs) 103 | 104 | assert participant.name == "some updated name" 105 | end 106 | 107 | test "update_participant/2 with invalid data returns error changeset" do 108 | participant = insert(:participant) 109 | assert {:error, %Ecto.Changeset{}} = Voting.update_participant(participant, @invalid_attrs) 110 | 111 | assert participant == 112 | Voting.get_participant!(participant.id, participant.organization_id) 113 | end 114 | 115 | test "delete_participant/1 deletes the participant" do 116 | participant = insert(:participant) 117 | assert {:ok, %Participant{}} = Voting.delete_participant(participant) 118 | 119 | assert_raise Ecto.NoResultsError, fn -> 120 | Voting.get_participant!(participant.id, participant.organization_id) 121 | end 122 | end 123 | 124 | test "change_participant/1 returns a participant changeset" do 125 | participant = insert(:participant) 126 | assert %Ecto.Changeset{} = Voting.change_participant(participant) 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /test/liquid_voting/voting_methods_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.VotingMethodsTest do 2 | use LiquidVoting.DataCase 3 | import LiquidVoting.Factory 4 | 5 | alias LiquidVoting.VotingMethods 6 | alias LiquidVoting.VotingMethods.VotingMethod 7 | 8 | describe "voting_methods" do 9 | @valid_attrs %{ 10 | name: "a_cool_voting_method", 11 | organization_id: Ecto.UUID.generate() 12 | } 13 | 14 | @invalid_attrs %{name: 42, organization_id: nil} 15 | 16 | test "get_voting_method!/2 returns the voting method for given id and organization_id" do 17 | voting_method = insert(:voting_method) 18 | 19 | assert VotingMethods.get_voting_method!(voting_method.id, voting_method.organization_id) == 20 | voting_method 21 | end 22 | 23 | test "get_voting_method_by_name!/2 returns the voting method for given name and organization_id" do 24 | voting_method = insert(:voting_method) 25 | 26 | assert VotingMethods.get_voting_method_by_name!( 27 | voting_method.name, 28 | voting_method.organization_id 29 | ) == 30 | voting_method 31 | end 32 | 33 | test "list_voting_methods_by_org/1 returns all voting_methods for an organization_id" do 34 | voting_method = insert(:voting_method) 35 | 36 | assert VotingMethods.list_voting_methods_by_org(voting_method.organization_id) == [ 37 | voting_method 38 | ] 39 | end 40 | 41 | test "upsert_voting_method/1 with valid data inserts a new voting method" do 42 | assert {:ok, %VotingMethod{} = voting_method} = 43 | VotingMethods.upsert_voting_method(@valid_attrs) 44 | 45 | assert voting_method.name == @valid_attrs[:name] 46 | assert voting_method.organization_id == @valid_attrs[:organization_id] 47 | end 48 | 49 | test "upsert_voting_method/1 with invalid data returns error changeset" do 50 | assert {:error, %Ecto.Changeset{}} = VotingMethods.upsert_voting_method(@invalid_attrs) 51 | end 52 | 53 | test "upsert_voting_method/1 with existing valid data fetches matching voting_method record" do 54 | insert(:voting_method, 55 | name: @valid_attrs[:name], 56 | organization_id: @valid_attrs[:organization_id] 57 | ) 58 | 59 | assert {:ok, %VotingMethod{} = voting_method} = 60 | VotingMethods.upsert_voting_method(@valid_attrs) 61 | 62 | assert voting_method.name == @valid_attrs[:name] 63 | assert voting_method.organization_id == @valid_attrs[:organization_id] 64 | end 65 | 66 | test "upsert_voting_method/1 with existing valid does not add a duplicate record" do 67 | voting_method = 68 | insert(:voting_method, 69 | name: @valid_attrs[:name], 70 | organization_id: @valid_attrs[:organization_id] 71 | ) 72 | 73 | assert Enum.count(VotingMethods.list_voting_methods_by_org(voting_method.organization_id)) == 74 | 1 75 | 76 | assert {:ok, %VotingMethod{}} = VotingMethods.upsert_voting_method(@valid_attrs) 77 | 78 | assert Enum.count(VotingMethods.list_voting_methods_by_org(voting_method.organization_id)) == 79 | 1 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /test/liquid_voting/voting_results_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.VotingResultsTest do 2 | use LiquidVoting.DataCase 3 | import LiquidVoting.Factory 4 | 5 | alias LiquidVoting.VotingResults 6 | alias LiquidVoting.VotingResults.Result 7 | 8 | describe "calculate_result!/3" do 9 | setup do 10 | voting_method = insert(:voting_method) 11 | vote = insert(:vote, voting_method: voting_method, yes: true) 12 | 13 | [ 14 | voting_method_id: vote.voting_method_id, 15 | voting_method: vote.voting_method, 16 | proposal_url: vote.proposal_url, 17 | organization_id: vote.organization_id 18 | ] 19 | end 20 | 21 | test "returns a result with the number of in_favor and no votes", context do 22 | assert %Result{in_favor: in_favor, against: against} = 23 | VotingResults.calculate_result!( 24 | context[:voting_method_id], 25 | context[:proposal_url], 26 | context[:organization_id] 27 | ) 28 | 29 | assert in_favor == 1 30 | assert against == 0 31 | end 32 | 33 | test "returns the same result struct for a given voting_method_id and proposal_url", 34 | context do 35 | %Result{id: id} = 36 | VotingResults.calculate_result!( 37 | context[:voting_method_id], 38 | context[:proposal_url], 39 | context[:organization_id] 40 | ) 41 | 42 | insert(:vote, 43 | voting_method: context[:voting_method], 44 | proposal_url: context[:proposal_url], 45 | organization_id: context[:organization_id] 46 | ) 47 | 48 | %Result{id: new_id} = 49 | VotingResults.calculate_result!( 50 | context[:voting_method_id], 51 | context[:proposal_url], 52 | context[:organization_id] 53 | ) 54 | 55 | assert id == new_id 56 | end 57 | end 58 | 59 | describe "create, get and list results" do 60 | setup do 61 | organization_id = Ecto.UUID.generate() 62 | voting_method = insert(:voting_method) 63 | 64 | [ 65 | valid_attrs: %{ 66 | in_favor: 42, 67 | against: 42, 68 | voting_method_id: voting_method.id, 69 | proposal_url: "https://proposals.com/1", 70 | organization_id: organization_id 71 | }, 72 | update_attrs: %{ 73 | in_favor: 43, 74 | against: 43, 75 | voting_method_id: voting_method.id, 76 | proposal_url: "https://proposals.com/1", 77 | organization_id: organization_id 78 | }, 79 | invalid_attrs: %{ 80 | in_favor: 42, 81 | against: 42, 82 | voting_method_id: voting_method.id, 83 | proposal_url: nil, 84 | organization_id: organization_id 85 | } 86 | ] 87 | end 88 | 89 | test "list_results/1 returns all results" do 90 | result = insert(:voting_result) 91 | assert VotingResults.list_results(result.organization_id) == [result] 92 | end 93 | 94 | test "list_results_for_proposal_url/2 returns all results for a proposal_url" do 95 | result = insert(:voting_result) 96 | 97 | assert VotingResults.list_results_for_proposal_url( 98 | result.proposal_url, 99 | result.organization_id 100 | ) == [result] 101 | end 102 | 103 | test "get_result!/2 returns the result with given id" do 104 | result = insert(:voting_result) 105 | assert VotingResults.get_result!(result.id, result.organization_id) == result 106 | end 107 | 108 | test "get_result_by_proposal_url/3 returns the result with given proposal_url and organization_id" do 109 | result = insert(:voting_result) 110 | 111 | assert VotingResults.get_result_by_proposal_url( 112 | result.voting_method_id, 113 | result.proposal_url, 114 | result.organization_id 115 | ) == result 116 | end 117 | 118 | test "get_result_by_proposal_url/3 with invalid data returns nil" do 119 | assert VotingResults.get_result_by_proposal_url( 120 | Ecto.UUID.generate(), 121 | "https://invalid.com", 122 | Ecto.UUID.generate() 123 | ) == 124 | nil 125 | end 126 | 127 | test "create_result/1 with valid data creates a result", context do 128 | assert {:ok, %Result{} = result} = VotingResults.create_result(context[:valid_attrs]) 129 | assert result.in_favor == 42 130 | assert result.against == 42 131 | end 132 | 133 | test "create_result/1 with invalid data returns error changeset", context do 134 | assert {:error, %Ecto.Changeset{}} = VotingResults.create_result(context[:invalid_attrs]) 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /test/liquid_voting/voting_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.VotingTest do 2 | use LiquidVoting.DataCase 3 | import LiquidVoting.Factory 4 | 5 | alias LiquidVoting.Voting 6 | alias LiquidVoting.Voting.Vote 7 | alias LiquidVoting.Delegations.Delegation 8 | 9 | describe "votes" do 10 | setup do 11 | participant = insert(:participant) 12 | voting_method = insert(:voting_method) 13 | 14 | [ 15 | valid_attrs: %{ 16 | yes: true, 17 | participant_id: participant.id, 18 | proposal_url: "http://proposals.com/1", 19 | voting_method_id: voting_method.id, 20 | organization_id: Ecto.UUID.generate() 21 | }, 22 | update_attrs: %{ 23 | yes: false, 24 | participant_id: participant.id, 25 | proposal_url: "http://proposals.com/2", 26 | voting_method_id: voting_method.id, 27 | organization_id: Ecto.UUID.generate() 28 | }, 29 | invalid_attrs: %{ 30 | yes: nil, 31 | participant_id: nil, 32 | proposal_url: nil, 33 | voting_method_id: nil, 34 | organization_id: nil 35 | } 36 | ] 37 | end 38 | 39 | test "create_vote/1 with valid data creates a vote", context do 40 | assert {:ok, %Vote{} = vote} = Voting.create_vote(context[:valid_attrs]) 41 | assert vote.yes == true 42 | end 43 | 44 | test "create_vote/1 with really long proposal urls still creates a vote", context do 45 | proposal_url = """ 46 | https://www.bigassstring.com/search?ei=WdznXfzyIoeT1fAP79yWqAc&q=chrome+extension+popup+js+xhr+onload+document.body&oq=chrome+extension+popup+js+xhr+onload+document.body&gs_l=psy-ab.3...309222.313422..314027...0.0..1.201.1696.5j9j1....2..0....1..gws-wiz.2OvPoKSwZ_I&ved=0ahUKEwi8g5fQspzmAhWHSRUIHW-uBXUQ4dUDCAs&uact=5" 47 | """ 48 | 49 | args = Map.merge(context[:valid_attrs], %{proposal_url: proposal_url}) 50 | assert {:ok, %Vote{}} = Voting.create_vote(args) 51 | end 52 | 53 | test "create_vote/1 deletes previous delegation by participant if present" do 54 | participant = insert(:participant) 55 | delegation = insert(:delegation, delegator: participant) 56 | voting_method = insert(:voting_method, name: "one-member-one-vote") 57 | 58 | assert {:ok, %Vote{}} = 59 | Voting.create_vote(%{ 60 | yes: false, 61 | participant_id: participant.id, 62 | proposal_url: "http://proposals.com/any", 63 | voting_method_id: voting_method.id, 64 | organization_id: delegation.organization_id 65 | }) 66 | 67 | assert LiquidVoting.Repo.get(Delegation, delegation.id) == nil 68 | end 69 | 70 | test "create_vote/1 with missing data returns error changeset", context do 71 | assert {:error, %Ecto.Changeset{}} = Voting.create_vote(context[:invalid_attrs]) 72 | end 73 | 74 | test "create_vote/1 with invalid proposal url returns error changeset", context do 75 | args = Map.merge(context[:valid_attrs], %{proposal_url: "bad url"}) 76 | assert {:error, %Ecto.Changeset{}} = Voting.create_vote(args) 77 | end 78 | 79 | test "create_vote/1 with duplicate data returns error changeset", context do 80 | Voting.create_vote(context[:valid_attrs]) 81 | assert {:error, %Ecto.Changeset{}} = Voting.create_vote(context[:valid_attrs]) 82 | end 83 | 84 | test "list_votes/1 returns all votes for an organization_id" do 85 | vote = insert(:vote) 86 | assert Voting.list_votes(vote.organization_id) == [vote] 87 | end 88 | 89 | test "list_votes_by_proposal/3 returns all votes for a voting_method_id, proposal_url and an organization_id" do 90 | vote = insert(:vote) 91 | insert(:vote, proposal_url: "https://different.org/different-proposal") 92 | 93 | assert Voting.list_votes_by_proposal( 94 | vote.voting_method_id, 95 | vote.proposal_url, 96 | vote.organization_id 97 | ) == [vote] 98 | end 99 | 100 | test "list_votes_by_participant/2 returns all votes for a participant_id and an organization_id" do 101 | vote = insert(:vote) 102 | assert Voting.list_votes_by_participant(vote.participant_id, vote.organization_id) == [vote] 103 | end 104 | 105 | test "get_vote!/2 returns the vote for given id and organization_id" do 106 | vote = insert(:vote) 107 | assert Voting.get_vote!(vote.id, vote.organization_id) == vote 108 | end 109 | 110 | test "get_vote!/4 returns the vote for given email, voting_method_id, proposal url and organization_id" do 111 | vote = insert(:vote) 112 | participant = Voting.get_participant!(vote.participant_id, vote.organization_id) 113 | 114 | assert Voting.get_vote!( 115 | participant.email, 116 | vote.voting_method_id, 117 | vote.proposal_url, 118 | vote.organization_id 119 | ) == vote 120 | end 121 | 122 | test "get_vote!/4 raises Ecto.NoResultsError if invalid attrs are passed in" do 123 | assert_raise Ecto.NoResultsError, fn -> 124 | Voting.get_vote!( 125 | "novote@gmail.com", 126 | "ad02e32c-6b18-4f62-9794-ac3c1e406e55", 127 | "https://apropos.com/not", 128 | "a6158b19-6bf6-4457-9d13-ef8b141611b4" 129 | ) 130 | end 131 | end 132 | 133 | test "get_vote_by_participant_id/2 returns the vote for a given participant id and proposal url" do 134 | vote = insert(:vote) 135 | 136 | assert Voting.get_vote_by_participant_id( 137 | vote.participant_id, 138 | vote.voting_method_id, 139 | vote.proposal_url, 140 | vote.organization_id 141 | ) == vote 142 | end 143 | 144 | test "get_vote_by_participant_id/2 returns nil if no result found matching arguments" do 145 | vote = insert(:vote) 146 | 147 | assert Voting.get_vote_by_participant_id( 148 | vote.participant_id, 149 | vote.voting_method_id, 150 | "https://proposals.com/non-existant-proposal", 151 | vote.organization_id 152 | ) == nil 153 | end 154 | 155 | test "update_vote/2 with valid data updates the vote", context do 156 | vote = insert(:vote) 157 | assert {:ok, %Vote{} = vote} = Voting.update_vote(vote, context[:update_attrs]) 158 | assert vote.yes == false 159 | end 160 | 161 | test "update_vote/2 with invalid data returns error changeset", context do 162 | vote = insert(:vote) 163 | assert {:error, %Ecto.Changeset{}} = Voting.update_vote(vote, context[:invalid_attrs]) 164 | end 165 | 166 | test "delete_vote/1 deletes the vote" do 167 | vote = insert(:vote) 168 | assert {:ok, %Vote{}} = Voting.delete_vote(vote) 169 | end 170 | 171 | test "delete_vote!/1 deletes the vote" do 172 | vote = insert(:vote) 173 | assert %Vote{} = Voting.delete_vote!(vote) 174 | assert_raise Ecto.StaleEntryError, fn -> Voting.delete_vote!(vote) end 175 | end 176 | 177 | test "change_vote/1 returns a vote changeset" do 178 | vote = insert(:vote) 179 | assert %Ecto.Changeset{} = Voting.change_vote(vote) 180 | end 181 | end 182 | end 183 | -------------------------------------------------------------------------------- /test/liquid_voting/voting_weight_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.VotingWeightTest do 2 | use LiquidVoting.DataCase 3 | 4 | import LiquidVoting.Factory 5 | 6 | alias LiquidVoting.{Repo, VotingWeight} 7 | 8 | describe "update_vote_weight/1 simplest scenario (global delegations)" do 9 | test "updates vote weight based on the number of delegations given to voter" do 10 | vote = insert(:vote) 11 | voter = Repo.preload(vote.participant, :delegations_received) 12 | 13 | assert length(voter.delegations_received) == 0 14 | assert vote.weight == 1 15 | 16 | insert(:delegation, delegate: voter) 17 | 18 | {:ok, vote} = VotingWeight.update_vote_weight(vote) 19 | 20 | assert vote.weight == 2 21 | end 22 | end 23 | 24 | describe "update_vote_weight/1 when delegators also have delegations given to them (global delegations)" do 25 | test "adds number of delegations the delegator received to the weight " do 26 | vote = insert(:vote) 27 | voter = vote.participant 28 | 29 | delegator = insert(:participant) 30 | delegator_to_the_delegator = insert(:participant) 31 | 32 | insert(:delegation, delegate: voter, delegator: delegator) 33 | insert(:delegation, delegate: delegator, delegator: delegator_to_the_delegator) 34 | 35 | {:ok, vote} = VotingWeight.update_vote_weight(vote) 36 | 37 | assert vote.weight == 3 38 | 39 | delegator_to_the_delegator_of_the_delegator = insert(:participant) 40 | 41 | insert(:delegation, delegate: delegator, delegator: delegator_to_the_delegator) 42 | 43 | insert(:delegation, 44 | delegate: delegator, 45 | delegator: delegator_to_the_delegator_of_the_delegator 46 | ) 47 | 48 | {:ok, vote} = VotingWeight.update_vote_weight(vote) 49 | 50 | assert vote.weight == 5 51 | 52 | insert(:delegation, delegate: voter) 53 | insert(:delegation, delegate: delegator) 54 | insert(:delegation, delegate: delegator_to_the_delegator_of_the_delegator) 55 | 56 | {:ok, vote} = VotingWeight.update_vote_weight(vote) 57 | 58 | assert vote.weight == 8 59 | end 60 | end 61 | 62 | describe "update_vote_weight/1 when voter has delegations for specific proposals" do 63 | test "only takes delegations for current proposal into account" do 64 | vote = insert(:vote) 65 | voter = Repo.preload(vote.participant, :delegations_received) 66 | 67 | assert length(voter.delegations_received) == 0 68 | assert vote.weight == 1 69 | 70 | insert(:delegation, 71 | delegate: voter, 72 | proposal_url: vote.proposal_url, 73 | voting_method: vote.voting_method 74 | ) 75 | 76 | another_voting_method = insert(:voting_method, organization_id: vote.organization_id) 77 | 78 | insert(:delegation, 79 | delegate: voter, 80 | proposal_url: "https://anotherproposal.com", 81 | voting_method: another_voting_method 82 | ) 83 | 84 | {:ok, vote} = VotingWeight.update_vote_weight(vote) 85 | 86 | assert vote.weight == 2 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /test/liquid_voting_web/absinthe/mutations/create_delegation/after_creating_related_votes_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVotingWeb.Absinthe.Mutations.CreateDelegation.AfterCreatingRelatedVotesTest do 2 | use LiquidVotingWeb.ConnCase 3 | import LiquidVoting.Factory 4 | 5 | alias LiquidVotingWeb.Schema.Schema 6 | 7 | describe "create proposal-specific delegation" do 8 | test "after creating related proposal vote by delegate" do 9 | # Insert vote and get related participant and proposal_url. 10 | vote = insert(:vote, yes: true) 11 | voter = vote.participant 12 | proposal_url = vote.proposal_url 13 | 14 | # Insert participant to act as delegator. 15 | delegator = insert(:participant, organization_id: vote.organization_id) 16 | 17 | # Create proposal_url delegation to participant (as delegate) and get 18 | # voting result for proposal_url. 19 | query = """ 20 | mutation { 21 | createDelegation(delegatorEmail: "#{delegator.email}", delegateEmail: "#{voter.email}", votingMethod: "#{ 22 | vote.voting_method.name 23 | }", proposalUrl: "#{proposal_url}") { 24 | proposalUrl 25 | votingResult { 26 | inFavor 27 | against 28 | } 29 | } 30 | } 31 | """ 32 | 33 | {:ok, %{data: %{"createDelegation" => delegation}}} = 34 | Absinthe.run(query, Schema, context: %{organization_id: vote.organization_id}) 35 | 36 | # Assert 'in favor' vount count now == 2, due to the delegation given to the voter 37 | assert delegation["proposalUrl"] == proposal_url 38 | assert delegation["votingResult"]["inFavor"] == 2 39 | assert delegation["votingResult"]["against"] == 0 40 | end 41 | 42 | test "when conflicting vote exists" do 43 | delegator = insert(:participant) 44 | vote = insert(:vote, participant: delegator, organization_id: delegator.organization_id) 45 | delegate = insert(:participant, organization_id: delegator.organization_id) 46 | 47 | query = """ 48 | mutation { 49 | createDelegation(delegatorEmail: "#{delegator.email}", delegateEmail: "#{delegate.email}", votingMethod: "#{ 50 | vote.voting_method.name 51 | }", proposalUrl: "#{vote.proposal_url}") { 52 | proposalUrl 53 | id 54 | } 55 | } 56 | """ 57 | 58 | {:ok, %{errors: [%{message: message, details: details}]}} = 59 | Absinthe.run(query, Schema, context: %{organization_id: delegator.organization_id}) 60 | 61 | assert message == "Could not create delegation." 62 | assert details == "Delegator has already voted on this proposal." 63 | end 64 | end 65 | 66 | describe "create global delegation" do 67 | test "after creating vote by delegate" do 68 | # Insert vote and get related participant and proposal_url. 69 | vote = insert(:vote, yes: false) 70 | voter = vote.participant 71 | proposal_url = vote.proposal_url 72 | 73 | # Insert participant to act as delegator 74 | delegator = insert(:participant, organization_id: vote.organization_id) 75 | 76 | # Create global delegation to participant (as delegate). 77 | # 78 | # This setup step needs to involve a call to create_delegation, as simply 79 | # inserting a factory delegation cannot be expected to call 80 | # calculate_result!, and thus we could not test the expected result is 81 | # returned in the final related query. Hence, an absinthe mutation is used. 82 | query = """ 83 | mutation { 84 | createDelegation(delegatorEmail: "#{delegator.email}", delegateEmail: "#{voter.email}") { 85 | delegator { 86 | email 87 | } 88 | delegate { 89 | email 90 | } 91 | } 92 | } 93 | """ 94 | 95 | {:ok, %{data: %{"createDelegation" => delegation}}} = 96 | Absinthe.run(query, Schema, context: %{organization_id: vote.organization_id}) 97 | 98 | assert delegation["delegator"]["email"] == delegator.email 99 | assert delegation["delegate"]["email"] == voter.email 100 | 101 | # Get result for proposal_B_url (should be 'against == 2'). 102 | query = """ 103 | query { 104 | votingResult(votingMethod: "#{vote.voting_method.name}", proposalUrl: "#{proposal_url}") { 105 | inFavor 106 | against 107 | proposalUrl 108 | } 109 | } 110 | """ 111 | 112 | {:ok, %{data: %{"votingResult" => result}}} = 113 | Absinthe.run(query, Schema, context: %{organization_id: vote.organization_id}) 114 | 115 | assert result["proposalUrl"] == proposal_url 116 | assert result["inFavor"] == 0 117 | assert result["against"] == 2 118 | end 119 | end 120 | 121 | describe "create proposal-specific and global delegations" do 122 | test "after creating related votes" do 123 | # Insert vote_A and get related participant and proposal_url. 124 | vote_A = insert(:vote, yes: true) 125 | voter_A = vote_A.participant 126 | proposal_A_url = vote_A.proposal_url 127 | 128 | # Insert vote_B and get related participant and proposal_url. 129 | vote_B = insert(:vote, yes: false, organization_id: vote_A.organization_id) 130 | voter_B = vote_B.participant 131 | proposal_B_url = vote_B.proposal_url 132 | 133 | # Insert participant to act as delegator. 134 | delegator = insert(:participant, organization_id: vote_A.organization_id) 135 | 136 | # Create proposal_A_url delegation to voter_A (as delegate) and get 137 | # voting result for proposal_A_url. 138 | query = """ 139 | mutation { 140 | createDelegation(delegatorEmail: "#{delegator.email}", delegateEmail: "#{voter_A.email}", votingMethod: "#{ 141 | vote_A.voting_method.name 142 | }", proposalUrl: "#{proposal_A_url}") { 143 | proposalUrl 144 | votingResult { 145 | inFavor 146 | against 147 | } 148 | } 149 | } 150 | """ 151 | 152 | {:ok, %{data: %{"createDelegation" => delegation}}} = 153 | Absinthe.run(query, Schema, context: %{organization_id: vote_A.organization_id}) 154 | 155 | # Assert 'in favor' vote count == 2, due to delegation given to voter_A. 156 | assert delegation["proposalUrl"] == proposal_A_url 157 | assert delegation["votingResult"]["inFavor"] == 2 158 | assert delegation["votingResult"]["against"] == 0 159 | 160 | # Create global delegation to voter_B (as delegate). 161 | # 162 | # This setup step needs to involve a call to create_delegation, as simply 163 | # inserting a factory delegation cannot be expected to call 164 | # calculate_result!, and thus we could not test the expected result is 165 | # returned in the final related query. Hence, an absinthe mutation is used. 166 | query = """ 167 | mutation { 168 | createDelegation(delegatorEmail: "#{delegator.email}", delegateEmail: "#{voter_B.email}") { 169 | delegator { 170 | email 171 | } 172 | delegate { 173 | email 174 | } 175 | } 176 | } 177 | """ 178 | 179 | {:ok, %{data: %{"createDelegation" => delegation}}} = 180 | Absinthe.run(query, Schema, context: %{organization_id: vote_A.organization_id}) 181 | 182 | assert delegation["delegator"]["email"] == delegator.email 183 | assert delegation["delegate"]["email"] == voter_B.email 184 | 185 | # Get result for proposal_B_url. 186 | query = """ 187 | query { 188 | votingResult(votingMethod: "#{vote_B.voting_method.name}", proposalUrl: "#{proposal_B_url}") { 189 | inFavor 190 | against 191 | proposalUrl 192 | } 193 | } 194 | """ 195 | 196 | {:ok, %{data: %{"votingResult" => result}}} = 197 | Absinthe.run(query, Schema, context: %{organization_id: vote_A.organization_id}) 198 | 199 | # Assert 'against' vote count == 2, due to delegation given to voter_B. 200 | assert result["proposalUrl"] == proposal_B_url 201 | assert result["inFavor"] == 0 202 | assert result["against"] == 2 203 | end 204 | end 205 | end 206 | -------------------------------------------------------------------------------- /test/liquid_voting_web/absinthe/mutations/create_delegation/before_creating_related_votes_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVotingWeb.Absinthe.Mutations.CreateDelegation.BeforeCreatingRelatedVotesTest do 2 | use LiquidVotingWeb.ConnCase 3 | import LiquidVoting.Factory 4 | 5 | alias LiquidVotingWeb.Schema.Schema 6 | 7 | describe "create proposal-specific delegation" do 8 | test "before creating related vote for delegator" do 9 | organization_id = Ecto.UUID.generate() 10 | voting_method = insert(:voting_method, organization_id: organization_id) 11 | 12 | delegation = 13 | insert(:delegation_for_proposal, 14 | voting_method: voting_method, 15 | organization_id: organization_id 16 | ) 17 | 18 | # Create vote by delegator and get details in queries. 19 | query = """ 20 | mutation { 21 | createVote(participantEmail: "#{delegation.delegator.email}", votingMethod: "#{ 22 | voting_method.name 23 | }", proposalUrl: "#{delegation.proposal_url}", yes: true) { 24 | proposalUrl 25 | participant { 26 | email 27 | } 28 | yes 29 | votingResult { 30 | inFavor 31 | against 32 | } 33 | votingMethod { 34 | name 35 | } 36 | } 37 | } 38 | """ 39 | 40 | {:ok, %{data: %{"createVote" => vote}}} = 41 | Absinthe.run(query, Schema, context: %{organization_id: organization_id}) 42 | 43 | assert vote["proposalUrl"] == delegation.proposal_url 44 | assert vote["participant"]["email"] == delegation.delegator.email 45 | assert vote["yes"] == true 46 | assert vote["votingResult"]["inFavor"] == 1 47 | assert vote["votingResult"]["against"] == 0 48 | assert vote["votingMethod"]["name"] == voting_method.name 49 | end 50 | 51 | test "before creating related vote for delegate" do 52 | organization_id = Ecto.UUID.generate() 53 | voting_method = insert(:voting_method, organization_id: organization_id) 54 | 55 | delegation = 56 | insert(:delegation_for_proposal, 57 | voting_method: voting_method, 58 | organization_id: organization_id 59 | ) 60 | 61 | # Create vote by delegator and get details in queries. 62 | query = """ 63 | mutation { 64 | createVote(participantEmail: "#{delegation.delegate.email}", votingMethod: "#{ 65 | voting_method.name 66 | }", proposalUrl: "#{delegation.proposal_url}", yes: true) { 67 | proposalUrl 68 | participant { 69 | email 70 | } 71 | yes 72 | votingResult { 73 | inFavor 74 | against 75 | } 76 | votingMethod { 77 | name 78 | } 79 | } 80 | } 81 | """ 82 | 83 | {:ok, %{data: %{"createVote" => vote}}} = 84 | Absinthe.run(query, Schema, context: %{organization_id: organization_id}) 85 | 86 | assert vote["proposalUrl"] == delegation.proposal_url 87 | assert vote["participant"]["email"] == delegation.delegate.email 88 | assert vote["yes"] == true 89 | assert vote["votingResult"]["inFavor"] == 2 90 | assert vote["votingResult"]["against"] == 0 91 | assert vote["votingMethod"]["name"] == voting_method.name 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /test/liquid_voting_web/absinthe/mutations/create_delegation/existing_delegator_delegate_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVotingWeb.Absinthe.Mutations.CreateDelegation.ExistingDelegatorDelegateTest do 2 | use LiquidVotingWeb.ConnCase 3 | import LiquidVoting.Factory 4 | 5 | alias LiquidVotingWeb.Schema.Schema 6 | 7 | describe "create delegation with existing delegator and delegate" do 8 | setup do 9 | delegator = insert(:participant) 10 | delegate = insert(:participant) 11 | 12 | [ 13 | delegator: delegator, 14 | delegate: delegate 15 | ] 16 | end 17 | 18 | test "with ids", context do 19 | query = """ 20 | mutation { 21 | createDelegation(delegatorId: "#{context[:delegator].id}", delegateId: "#{ 22 | context[:delegate].id 23 | }") { 24 | delegator { 25 | email 26 | } 27 | delegate { 28 | email 29 | } 30 | } 31 | } 32 | """ 33 | 34 | {:ok, %{data: %{"createDelegation" => delegation}}} = 35 | Absinthe.run(query, Schema, context: %{organization_id: Ecto.UUID.generate()}) 36 | 37 | assert delegation["delegator"]["email"] == context[:delegator].email 38 | assert delegation["delegate"]["email"] == context[:delegate].email 39 | end 40 | 41 | test "with emails", context do 42 | query = """ 43 | mutation { 44 | createDelegation(delegatorEmail: "#{context[:delegator].email}", delegateEmail: "#{ 45 | context[:delegate].email 46 | }") { 47 | delegator { 48 | email 49 | } 50 | delegate { 51 | email 52 | } 53 | } 54 | } 55 | """ 56 | 57 | {:ok, %{data: %{"createDelegation" => delegation}}} = 58 | Absinthe.run(query, Schema, context: %{organization_id: Ecto.UUID.generate()}) 59 | 60 | assert delegation["delegator"]["email"] == context[:delegator].email 61 | assert delegation["delegate"]["email"] == context[:delegate].email 62 | end 63 | 64 | test "with missing field", context do 65 | query = """ 66 | mutation { 67 | createDelegation(delegatorId: "#{context[:delegator].id}") { 68 | delegator { 69 | email 70 | } 71 | delegate { 72 | email 73 | } 74 | } 75 | } 76 | """ 77 | 78 | {:ok, %{errors: [%{message: message, details: details}]}} = 79 | Absinthe.run(query, Schema, context: %{organization_id: Ecto.UUID.generate()}) 80 | 81 | assert message == "Could not create delegation" 82 | assert details == %{delegate_id: ["can't be blank"]} 83 | end 84 | 85 | test "with identical emails for delegator and delegate", context do 86 | query = """ 87 | mutation { 88 | createDelegation(delegatorEmail: "#{context[:delegator].email}", delegateEmail: "#{ 89 | context[:delegator].email 90 | }") { 91 | delegator { 92 | email 93 | } 94 | delegate { 95 | email 96 | } 97 | } 98 | } 99 | """ 100 | 101 | {:ok, %{errors: [%{message: message, details: details}]}} = 102 | Absinthe.run(query, Schema, context: %{organization_id: Ecto.UUID.generate()}) 103 | 104 | assert message == "Could not create delegation" 105 | assert details == %{delegate_id: ["delegator and delegate must be different"]} 106 | end 107 | 108 | test "with identical ids for delegator and delegate", context do 109 | query = """ 110 | mutation { 111 | createDelegation(delegatorId: "#{context[:delegator].id}", delegateId: "#{ 112 | context[:delegator].id 113 | }") { 114 | delegator { 115 | email 116 | } 117 | delegate { 118 | email 119 | } 120 | } 121 | } 122 | """ 123 | 124 | {:ok, %{errors: [%{message: message, details: details}]}} = 125 | Absinthe.run(query, Schema, context: %{organization_id: Ecto.UUID.generate()}) 126 | 127 | assert message == "Could not create delegation" 128 | assert details == %{delegate_id: ["delegator and delegate must be different"]} 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /test/liquid_voting_web/absinthe/mutations/create_delegation/new_participants_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVotingWeb.Absinthe.Mutations.CreateDelegation.NewParticipantsTest do 2 | use LiquidVotingWeb.ConnCase 3 | import LiquidVoting.Factory 4 | 5 | alias LiquidVotingWeb.Schema.Schema 6 | 7 | @delegator_email "delegator@email.com" 8 | @delegate_email "delegate@email.com" 9 | @proposal_url "https://www.proposal.com/my" 10 | 11 | describe "create delegation with new participants" do 12 | test "with emails" do 13 | query = """ 14 | mutation { 15 | createDelegation(delegatorEmail: "#{@delegator_email}", delegateEmail: "#{@delegate_email}") { 16 | delegator { 17 | email 18 | name 19 | } 20 | delegate { 21 | email 22 | name 23 | } 24 | } 25 | } 26 | """ 27 | 28 | {:ok, %{data: %{"createDelegation" => delegation}}} = 29 | Absinthe.run(query, Schema, context: %{organization_id: Ecto.UUID.generate()}) 30 | 31 | assert delegation["delegator"]["email"] == @delegator_email 32 | assert delegation["delegator"]["name"] == nil 33 | assert delegation["delegate"]["email"] == @delegate_email 34 | assert delegation["delegate"]["name"] == nil 35 | end 36 | 37 | test "including a proposal url" do 38 | query = """ 39 | mutation { 40 | createDelegation(delegatorEmail: "#{@delegator_email}", delegateEmail: "#{@delegate_email}", proposalUrl: "#{ 41 | @proposal_url 42 | }") { 43 | delegator { 44 | email 45 | } 46 | delegate { 47 | email 48 | } 49 | proposalUrl 50 | } 51 | } 52 | """ 53 | 54 | {:ok, %{data: %{"createDelegation" => delegation}}} = 55 | Absinthe.run(query, Schema, context: %{organization_id: Ecto.UUID.generate()}) 56 | 57 | assert delegation["proposalUrl"] == @proposal_url 58 | end 59 | 60 | test "including results in response, if present" do 61 | result = insert(:voting_result, proposal_url: @proposal_url) 62 | 63 | query = """ 64 | mutation { 65 | createDelegation(delegatorEmail: "#{@delegator_email}", delegateEmail: "#{@delegate_email}", proposalUrl: "#{ 66 | @proposal_url 67 | }") { 68 | delegator { 69 | email 70 | } 71 | delegate { 72 | email 73 | } 74 | proposalUrl 75 | votingResult { 76 | in_favor 77 | against 78 | } 79 | } 80 | } 81 | """ 82 | 83 | {:ok, %{data: %{"createDelegation" => delegation}}} = 84 | Absinthe.run(query, Schema, context: %{organization_id: result.organization_id}) 85 | 86 | assert delegation["votingResult"]["in_favor"] == 0 87 | assert delegation["votingResult"]["against"] == 0 88 | end 89 | 90 | test "with missing fields" do 91 | query = """ 92 | mutation { 93 | createDelegation(delegatorEmail: "#{@delegator_email}") { 94 | delegator { 95 | email 96 | name 97 | } 98 | delegate { 99 | email 100 | name 101 | } 102 | } 103 | } 104 | """ 105 | 106 | {:ok, %{errors: [%{message: message, details: details}]}} = 107 | Absinthe.run(query, Schema, context: %{organization_id: Ecto.UUID.generate()}) 108 | 109 | assert message == "Could not create delegation" 110 | assert details == %{delegate_email: ["can't be blank"]} 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /test/liquid_voting_web/absinthe/mutations/create_delegation/with_emails/existing_delegations_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVotingWeb.Absinthe.Mutations.CreateDelegation.WithEmails.ExistingDelegationsTest do 2 | use LiquidVotingWeb.ConnCase 3 | import LiquidVoting.Factory 4 | 5 | alias LiquidVotingWeb.Schema.Schema 6 | 7 | describe "create global delegation when a global delegation for a different delegate already exists" do 8 | test "overwrites existing global delegation" do 9 | global_delegation = insert(:delegation) 10 | another_delegate = insert(:participant, organization_id: global_delegation.organization_id) 11 | 12 | query = """ 13 | mutation { 14 | createDelegation(delegatorEmail: "#{global_delegation.delegator.email}", delegateEmail: "#{ 15 | another_delegate.email 16 | }") { 17 | delegator { 18 | email 19 | name 20 | } 21 | delegate { 22 | email 23 | name 24 | } 25 | id 26 | } 27 | } 28 | """ 29 | 30 | {:ok, %{data: %{"createDelegation" => updated_delegation}}} = 31 | Absinthe.run(query, Schema, context: %{organization_id: global_delegation.organization_id}) 32 | 33 | assert updated_delegation["delegator"]["email"] == global_delegation.delegator.email 34 | assert updated_delegation["delegate"]["email"] == another_delegate.email 35 | assert updated_delegation["id"] == global_delegation.id 36 | end 37 | end 38 | 39 | describe "create global delegation when proposal delegation to same delegate already exists" do 40 | test "returns the new global delegation" do 41 | proposal_delegation = insert(:delegation_for_proposal) 42 | 43 | query = """ 44 | mutation { 45 | createDelegation(delegatorEmail: "#{proposal_delegation.delegator.email}", delegateEmail: "#{ 46 | proposal_delegation.delegate.email 47 | }") { 48 | delegator { 49 | email 50 | } 51 | delegate { 52 | email 53 | } 54 | } 55 | } 56 | """ 57 | 58 | {:ok, %{data: %{"createDelegation" => global_delegation}}} = 59 | Absinthe.run(query, Schema, 60 | context: %{organization_id: proposal_delegation.organization_id} 61 | ) 62 | 63 | assert global_delegation["delegator"]["email"] == proposal_delegation.delegator.email 64 | assert global_delegation["delegate"]["email"] == proposal_delegation.delegate.email 65 | end 66 | end 67 | 68 | describe "create proposal delegation when delegation for same proposal already exists" do 69 | test "overwrites existing proposal-specific delegation" do 70 | proposal_delegation = insert(:delegation_for_proposal) 71 | 72 | another_delegate = 73 | insert(:participant, organization_id: proposal_delegation.organization_id) 74 | 75 | query = """ 76 | mutation { 77 | createDelegation(delegatorEmail: "#{proposal_delegation.delegator.email}", delegateEmail: "#{ 78 | another_delegate.email 79 | }", votingMethod: "#{proposal_delegation.voting_method.name}", proposalUrl: "#{ 80 | proposal_delegation.proposal_url 81 | }") { 82 | delegator { 83 | email 84 | } 85 | delegate { 86 | email 87 | } 88 | proposalUrl 89 | id 90 | } 91 | } 92 | """ 93 | 94 | {:ok, %{data: %{"createDelegation" => updated_delegation}}} = 95 | Absinthe.run(query, Schema, 96 | context: %{organization_id: proposal_delegation.organization_id} 97 | ) 98 | 99 | assert updated_delegation["delegator"]["email"] == proposal_delegation.delegator.email 100 | assert updated_delegation["delegate"]["email"] == another_delegate.email 101 | assert updated_delegation["proposalUrl"] == proposal_delegation.proposal_url 102 | assert updated_delegation["id"] == proposal_delegation.id 103 | end 104 | end 105 | 106 | describe "create proposal delegation when global delegation to same delegate already exists" do 107 | test "returns error" do 108 | global_delegation = insert(:delegation) 109 | 110 | query = """ 111 | mutation { 112 | createDelegation(delegatorEmail: "#{global_delegation.delegator.email}", delegateEmail: "#{ 113 | global_delegation.delegate.email 114 | }", proposalUrl: "https://www.proposal.com/1") { 115 | delegator { 116 | email 117 | } 118 | delegate { 119 | email 120 | } 121 | proposalUrl 122 | } 123 | } 124 | """ 125 | 126 | {:ok, %{errors: [%{details: details, message: message}]}} = 127 | Absinthe.run(query, Schema, context: %{organization_id: global_delegation.organization_id}) 128 | 129 | assert message == "Could not create delegation." 130 | assert details == "A global delegation for the same participants already exists." 131 | end 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /test/liquid_voting_web/absinthe/mutations/create_delegation/with_ids/existing_delegations_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVotingWeb.Absinthe.Mutations.CreateDelegation.WithIds.ExistingDelegationsTest do 2 | use LiquidVotingWeb.ConnCase 3 | import LiquidVoting.Factory 4 | 5 | alias LiquidVotingWeb.Schema.Schema 6 | 7 | describe "create global delegation when a global delegation for a different delegate already exists" do 8 | test "overwrites existing global delegation" do 9 | global_delegation = insert(:delegation) 10 | another_delegate = insert(:participant, organization_id: global_delegation.organization_id) 11 | 12 | query = """ 13 | mutation { 14 | createDelegation(delegatorId: "#{global_delegation.delegator.id}", delegateId: "#{ 15 | another_delegate.id 16 | }") { 17 | delegator { 18 | id 19 | name 20 | } 21 | delegate { 22 | id 23 | name 24 | } 25 | id 26 | } 27 | } 28 | """ 29 | 30 | {:ok, %{data: %{"createDelegation" => updated_delegation}}} = 31 | Absinthe.run(query, Schema, context: %{organization_id: global_delegation.organization_id}) 32 | 33 | assert updated_delegation["delegator"]["id"] == global_delegation.delegator.id 34 | assert updated_delegation["delegate"]["id"] == another_delegate.id 35 | assert updated_delegation["id"] == global_delegation.id 36 | end 37 | end 38 | 39 | describe "create global delegation when proposal delegation to same delegate already exists" do 40 | test "returns the new global delegation" do 41 | proposal_delegation = insert(:delegation_for_proposal) 42 | 43 | query = """ 44 | mutation { 45 | createDelegation(delegatorId: "#{proposal_delegation.delegator.id}", delegateId: "#{ 46 | proposal_delegation.delegate.id 47 | }") { 48 | delegator { 49 | id 50 | } 51 | delegate { 52 | id 53 | } 54 | } 55 | } 56 | """ 57 | 58 | {:ok, %{data: %{"createDelegation" => global_delegation}}} = 59 | Absinthe.run(query, Schema, 60 | context: %{organization_id: proposal_delegation.organization_id} 61 | ) 62 | 63 | assert global_delegation["delegator"]["id"] == proposal_delegation.delegator.id 64 | assert global_delegation["delegate"]["id"] == proposal_delegation.delegate.id 65 | end 66 | end 67 | 68 | describe "create proposal delegation when delegation for same proposal already exists" do 69 | test "overwrites existing proposal-specific delegation" do 70 | proposal_delegation = insert(:delegation_for_proposal) 71 | proposal_voting_method_name = proposal_delegation.voting_method.name 72 | 73 | another_delegate = 74 | insert(:participant, organization_id: proposal_delegation.organization_id) 75 | 76 | query = """ 77 | mutation { 78 | createDelegation(delegatorId: "#{proposal_delegation.delegator.id}", delegateId: "#{ 79 | another_delegate.id 80 | }", votingMethod: "#{proposal_voting_method_name}", proposalUrl: "#{ 81 | proposal_delegation.proposal_url 82 | }") { 83 | delegator { 84 | id 85 | } 86 | delegate { 87 | id 88 | } 89 | proposalUrl 90 | id 91 | } 92 | } 93 | """ 94 | 95 | {:ok, %{data: %{"createDelegation" => updated_delegation}}} = 96 | Absinthe.run(query, Schema, 97 | context: %{organization_id: proposal_delegation.organization_id} 98 | ) 99 | 100 | assert updated_delegation["delegator"]["id"] == proposal_delegation.delegator.id 101 | assert updated_delegation["delegate"]["id"] == another_delegate.id 102 | assert updated_delegation["proposalUrl"] == proposal_delegation.proposal_url 103 | assert updated_delegation["id"] == proposal_delegation.id 104 | end 105 | end 106 | 107 | describe "create proposal delegation when global delegation to same delegate already exists" do 108 | test "returns error" do 109 | global_delegation = insert(:delegation) 110 | 111 | query = """ 112 | mutation { 113 | createDelegation(delegatorId: "#{global_delegation.delegator.id}", delegateId: "#{ 114 | global_delegation.delegate.id 115 | }", proposalUrl: "https://www.proposal.com/1") { 116 | delegator { 117 | id 118 | } 119 | delegate { 120 | id 121 | } 122 | proposalUrl 123 | } 124 | } 125 | """ 126 | 127 | {:ok, %{errors: [%{details: details, message: message}]}} = 128 | Absinthe.run(query, Schema, context: %{organization_id: global_delegation.organization_id}) 129 | 130 | assert message == "Could not create delegation." 131 | assert details == "A global delegation for the same participants already exists." 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /test/liquid_voting_web/absinthe/mutations/create_participant_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVotingWeb.Absinthe.Mutations.CreateParticipantTest do 2 | use LiquidVotingWeb.ConnCase 3 | 4 | alias LiquidVotingWeb.Schema.Schema 5 | 6 | describe "create participant" do 7 | @new_participant_email "noob@email.com" 8 | @new_participant_name "Noobie" 9 | @another_name "Another Name" 10 | @invalid_email "invalid_email" 11 | @organization_id Ecto.UUID.generate() 12 | 13 | test "with a new participant's email and name" do 14 | query = """ 15 | mutation { 16 | createParticipant(email: "#{@new_participant_email}", name: "#{@new_participant_name}") { 17 | name 18 | email 19 | } 20 | } 21 | """ 22 | 23 | {:ok, %{data: %{"createParticipant" => participant}}} = 24 | Absinthe.run(query, Schema, context: %{organization_id: @organization_id}) 25 | 26 | assert participant["email"] == @new_participant_email 27 | assert participant["name"] == @new_participant_name 28 | end 29 | 30 | test "with an existing participant's email returns error changeset" do 31 | query = """ 32 | mutation { 33 | createParticipant(email: "#{@new_participant_email}", name: "#{@new_participant_name}") { 34 | name 35 | email 36 | } 37 | } 38 | """ 39 | 40 | {:ok, _} = Absinthe.run(query, Schema, context: %{organization_id: @organization_id}) 41 | 42 | query = """ 43 | mutation { 44 | createParticipant(email: "#{@new_participant_email}", name: "#{@another_name}") { 45 | name 46 | email 47 | } 48 | } 49 | """ 50 | 51 | {:ok, %{errors: [%{message: message, details: details}]}} = 52 | Absinthe.run(query, Schema, context: %{organization_id: @organization_id}) 53 | 54 | assert message == "Could not create participant" 55 | assert details == %{email: ["has already been taken"]} 56 | end 57 | 58 | test "with only a new participant's email returns error changeset" do 59 | query = """ 60 | mutation { 61 | createParticipant(email: "#{@new_participant_email}") { 62 | name 63 | email 64 | } 65 | } 66 | """ 67 | 68 | {:ok, %{errors: [%{message: message}]}} = 69 | Absinthe.run(query, Schema, context: %{organization_id: @organization_id}) 70 | 71 | assert to_charlist(message) == 'In argument "name": Expected type "String!", found null.' 72 | end 73 | 74 | test "with only a new participant's name returns error changeset" do 75 | query = """ 76 | mutation { 77 | createParticipant(name: "#{@new_participant_name}") { 78 | name 79 | email 80 | } 81 | } 82 | """ 83 | 84 | {:ok, %{errors: [%{message: message}]}} = 85 | Absinthe.run(query, Schema, context: %{organization_id: @organization_id}) 86 | 87 | assert to_charlist(message) == 'In argument "email": Expected type "String!", found null.' 88 | end 89 | 90 | test "with invalid email format returns error changeset" do 91 | query = """ 92 | mutation { 93 | createParticipant(email: "#{@invalid_email}", name: "#{@new_participant_name}") { 94 | name 95 | email 96 | } 97 | } 98 | """ 99 | 100 | {:ok, %{errors: [%{message: message, details: details}]}} = 101 | Absinthe.run(query, Schema, context: %{organization_id: @organization_id}) 102 | 103 | assert message == "Could not create participant" 104 | assert details == %{email: ["is invalid"]} 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /test/liquid_voting_web/absinthe/mutations/create_vote_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVotingWeb.Absinthe.Mutations.CreateVoteTest do 2 | use LiquidVotingWeb.ConnCase 3 | import LiquidVoting.Factory 4 | 5 | alias LiquidVotingWeb.Schema.Schema 6 | 7 | describe "create vote" do 8 | setup do 9 | participant = insert(:participant) 10 | voting_method = insert(:voting_method, organization_id: participant.organization_id) 11 | 12 | [ 13 | participant_id: participant.id, 14 | participant_email: participant.email, 15 | new_participant_email: "noob@email.com", 16 | proposal_url: "https://github.com/user/repo/pulls/15", 17 | yes: true, 18 | voting_method_name: voting_method.name, 19 | organization_id: participant.organization_id 20 | ] 21 | end 22 | 23 | test "with a new participant's email and no voting_method name ", context do 24 | query = """ 25 | mutation { 26 | createVote(participantEmail: "#{context[:new_participant_email]}", proposalUrl:"#{ 27 | context[:proposal_url] 28 | }", yes: #{context[:yes]}) { 29 | participant { 30 | email 31 | } 32 | votingMethod{ 33 | name 34 | } 35 | yes 36 | } 37 | } 38 | """ 39 | 40 | {:ok, %{data: %{"createVote" => vote}}} = 41 | Absinthe.run(query, Schema, context: %{organization_id: context[:organization_id]}) 42 | 43 | assert vote["participant"]["email"] == context[:new_participant_email] 44 | assert vote["votingMethod"]["name"] == "default" 45 | assert vote["yes"] == context[:yes] 46 | end 47 | 48 | test "with a new participant's email and voting_method name", context do 49 | query = """ 50 | mutation { 51 | createVote(participantEmail: "#{context[:new_participant_email]}", proposalUrl:"#{ 52 | context[:proposal_url] 53 | }", votingMethod: "#{context[:voting_method_name]}", yes: #{context[:yes]}) { 54 | participant { 55 | email 56 | } 57 | votingMethod{ 58 | name 59 | } 60 | yes 61 | } 62 | } 63 | """ 64 | 65 | {:ok, %{data: %{"createVote" => vote}}} = 66 | Absinthe.run(query, Schema, context: %{organization_id: context[:organization_id]}) 67 | 68 | assert vote["participant"]["email"] == context[:new_participant_email] 69 | assert vote["votingMethod"]["name"] == context[:voting_method_name] 70 | assert vote["yes"] == context[:yes] 71 | end 72 | 73 | test "with an existing participant's email and voting_method name", context do 74 | query = """ 75 | mutation { 76 | createVote(participantEmail: "#{context[:participant_email]}", proposalUrl:"#{ 77 | context[:proposal_url] 78 | }", votingMethod: "#{context[:voting_method_name]}", yes: #{context[:yes]}) { 79 | participant { 80 | email 81 | } 82 | votingMethod{ 83 | name 84 | } 85 | yes 86 | } 87 | } 88 | """ 89 | 90 | {:ok, %{data: %{"createVote" => vote}}} = 91 | Absinthe.run(query, Schema, context: %{organization_id: context[:organization_id]}) 92 | 93 | assert vote["participant"]["email"] == context[:participant_email] 94 | assert vote["votingMethod"]["name"] == context[:voting_method_name] 95 | assert vote["yes"] == context[:yes] 96 | end 97 | 98 | test "with an existing participant's email and no voting_method name", context do 99 | query = """ 100 | mutation { 101 | createVote(participantEmail: "#{context[:participant_email]}", proposalUrl:"#{ 102 | context[:proposal_url] 103 | }", yes: #{context[:yes]}) { 104 | participant { 105 | email 106 | } 107 | votingMethod{ 108 | name 109 | } 110 | yes 111 | } 112 | } 113 | """ 114 | 115 | {:ok, %{data: %{"createVote" => vote}}} = 116 | Absinthe.run(query, Schema, context: %{organization_id: context[:organization_id]}) 117 | 118 | assert vote["participant"]["email"] == context[:participant_email] 119 | assert vote["votingMethod"]["name"] == "default" 120 | assert vote["yes"] == context[:yes] 121 | end 122 | 123 | test "including voting results in the response", context do 124 | query = """ 125 | mutation { 126 | createVote(participantEmail: "#{context[:participant_email]}", proposalUrl:"#{ 127 | context[:proposal_url] 128 | }", votingMethod: "#{context[:voting_method_name]}", yes: #{context[:yes]}) { 129 | participant { 130 | email 131 | } 132 | yes 133 | proposalUrl 134 | votingResult { 135 | in_favor 136 | against 137 | } 138 | } 139 | } 140 | """ 141 | 142 | {:ok, %{data: %{"createVote" => vote}}} = 143 | Absinthe.run(query, Schema, context: %{organization_id: context[:organization_id]}) 144 | 145 | assert vote["votingResult"]["in_favor"] == 1 146 | assert vote["votingResult"]["against"] == 0 147 | end 148 | 149 | test "with participant's id", context do 150 | query = """ 151 | mutation { 152 | createVote(participantId: "#{context[:participant_id]}", proposalUrl:"#{ 153 | context[:proposal_url] 154 | }", votingMethod: "#{context[:voting_method_name]}", yes: #{context[:yes]}) { 155 | participant { 156 | email 157 | } 158 | yes 159 | } 160 | } 161 | """ 162 | 163 | {:ok, %{data: %{"createVote" => vote}}} = 164 | Absinthe.run(query, Schema, context: %{organization_id: context[:organization_id]}) 165 | 166 | assert vote["participant"]["email"] == context[:participant_email] 167 | assert vote["yes"] == context[:yes] 168 | end 169 | 170 | test "with no participant identifiers", context do 171 | query = """ 172 | mutation { 173 | createVote(proposalUrl:"#{context[:proposal_url]}", votingMethod: "#{ 174 | context[:voting_method] 175 | }", yes: #{context[:yes]}) { 176 | participant { 177 | email 178 | } 179 | yes 180 | } 181 | } 182 | """ 183 | 184 | {:ok, %{errors: [%{message: message, details: details}]}} = 185 | Absinthe.run(query, Schema, context: %{organization_id: context[:organization_id]}) 186 | 187 | assert message == "Could not create vote" 188 | assert details == "No participant identifier (id or email) submitted" 189 | end 190 | 191 | test "when created after proposal-specific delegation" do 192 | # Insert a proposal-specific delegation. 193 | delegation = insert(:delegation_for_proposal) 194 | delegate = delegation.delegate 195 | proposal_url = delegation.proposal_url 196 | voting_method_name = delegation.voting_method.name 197 | 198 | # Create a vote, cast by the proposal-specific delegation's delegate, 199 | # and query the votingResult for the proposal_url of the vote. 200 | query = """ 201 | mutation { 202 | createVote(participantEmail: "#{delegate.email}", proposalUrl: "#{proposal_url}", votingMethod: "#{ 203 | voting_method_name 204 | }", yes: true) { 205 | proposalUrl 206 | votingResult { 207 | inFavor 208 | against 209 | } 210 | } 211 | } 212 | """ 213 | 214 | {:ok, %{data: %{"createVote" => vote}}} = 215 | Absinthe.run(query, Schema, context: %{organization_id: delegation.organization_id}) 216 | 217 | # Assert the voting result includes the voting weight due to delegation, 218 | # in addition to the delegate's default vote-weight of 1. 219 | assert vote["proposalUrl"] == proposal_url 220 | assert vote["votingResult"]["inFavor"] == 2 221 | assert vote["votingResult"]["against"] == 0 222 | end 223 | 224 | test "when created after global delegation" do 225 | # Insert a global delegation. 226 | delegation = insert(:delegation) 227 | delegate = delegation.delegate 228 | 229 | proposal_url = "https://proposals/1" 230 | 231 | # Create a vote, cast by the global delegation's delegate, and query 232 | # the votingResult for the proposal_url of the vote. 233 | query = """ 234 | mutation { 235 | createVote(participantEmail: "#{delegate.email}", proposalUrl: "#{proposal_url}", votingMethod: "a-voting-method", yes: false) { 236 | proposalUrl 237 | votingResult { 238 | inFavor 239 | against 240 | } 241 | } 242 | } 243 | """ 244 | 245 | {:ok, %{data: %{"createVote" => vote}}} = 246 | Absinthe.run(query, Schema, context: %{organization_id: delegation.organization_id}) 247 | 248 | # Assert the voting result includes the voting weight due to delegation, 249 | # in addition to the delegate's default vote-weight of 1. 250 | assert vote["proposalUrl"] == proposal_url 251 | assert vote["votingResult"]["inFavor"] == 0 252 | assert vote["votingResult"]["against"] == 2 253 | end 254 | 255 | # This tests 2 separate, but related, scenarios - to help test that voting 256 | # results are correctly calculated when the same participant is delegator 257 | # for multiple delegations: 258 | # 259 | # First, a global delegation is created. 260 | # 261 | # Second, a proposal-specific delegation (for proposal_A_url), with the same 262 | # delegator, but a different delegate, is created. 263 | # 264 | # Third, the delegate of the proposal-specific delegation casts a vote (against). 265 | # 266 | # Fourth, the delegate of the global delegation casts a vote (in favor) for 267 | # a separate proposal (proposal_B_url). 268 | # 269 | # Lastly, we assert that the voting results returned in a query within the 270 | # createVote mutations contain the expected vote counts - 2 votes 'against' 271 | # for proposal_A_url, and 2 votes 'in_favor' for proposal_B_url. 272 | test "when created after related global and proposal delegations" do 273 | global_delegation = insert(:delegation) 274 | 275 | proposal_delegation = 276 | insert(:delegation_for_proposal, 277 | delegator: global_delegation.delegator, 278 | organization_id: global_delegation.organization_id 279 | ) 280 | 281 | proposal_delegate = proposal_delegation.delegate 282 | proposal_A_url = proposal_delegation.proposal_url 283 | proposal_A_voting_method_name = proposal_delegation.voting_method.name 284 | 285 | proposal_B_url = "https://proposals/b" 286 | proposal_B_voting_method_name = "proposal-B-voting-method" 287 | 288 | # Create a vote for 'proposal A', cast by the proposal-specific delegation's delegate. 289 | query = """ 290 | mutation { 291 | createVote(participantEmail: "#{proposal_delegate.email}", proposalUrl: "#{proposal_A_url}", votingMethod: "#{ 292 | proposal_A_voting_method_name 293 | }", yes: false) { 294 | proposalUrl 295 | votingMethod{ 296 | name 297 | } 298 | votingResult { 299 | inFavor 300 | against 301 | } 302 | } 303 | } 304 | """ 305 | 306 | {:ok, %{data: %{"createVote" => vote}}} = 307 | Absinthe.run(query, Schema, context: %{organization_id: global_delegation.organization_id}) 308 | 309 | assert vote["proposalUrl"] == proposal_A_url 310 | assert vote["votingMethod"]["name"] == proposal_A_voting_method_name 311 | assert vote["votingResult"]["inFavor"] == 0 312 | assert vote["votingResult"]["against"] == 2 313 | 314 | # Create a vote for 'proposal B', cast by the global delegation's delegate. 315 | query = """ 316 | mutation { 317 | createVote(participantEmail: "#{global_delegation.delegate.email}", proposalUrl: "#{ 318 | proposal_B_url 319 | }", votingMethod: "#{proposal_B_voting_method_name}", yes: true) { 320 | proposalUrl 321 | votingMethod{ 322 | name 323 | } 324 | votingResult { 325 | inFavor 326 | against 327 | } 328 | } 329 | } 330 | """ 331 | 332 | {:ok, %{data: %{"createVote" => vote}}} = 333 | Absinthe.run(query, Schema, context: %{organization_id: global_delegation.organization_id}) 334 | 335 | assert vote["proposalUrl"] == proposal_B_url 336 | assert vote["votingMethod"]["name"] == proposal_B_voting_method_name 337 | assert vote["votingResult"]["inFavor"] == 2 338 | assert vote["votingResult"]["against"] == 0 339 | end 340 | end 341 | end 342 | -------------------------------------------------------------------------------- /test/liquid_voting_web/absinthe/mutations/delete_delegation_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVotingWeb.Absinthe.Mutations.DeleteDelegationTest do 2 | use LiquidVotingWeb.ConnCase 3 | import LiquidVoting.Factory 4 | 5 | alias LiquidVotingWeb.Schema.Schema 6 | 7 | describe "delete delegation specific to a proposal" do 8 | setup do 9 | delegation = insert(:delegation_for_proposal) 10 | 11 | insert(:vote, 12 | participant: delegation.delegate, 13 | proposal_url: delegation.proposal_url, 14 | voting_method: delegation.voting_method, 15 | organization_id: delegation.organization_id 16 | ) 17 | 18 | insert(:voting_result, 19 | in_favor: 2, 20 | proposal_url: delegation.proposal_url, 21 | voting_method: delegation.voting_method, 22 | organization_id: delegation.organization_id 23 | ) 24 | 25 | [ 26 | delegator_email: delegation.delegator.email, 27 | delegate_email: delegation.delegate.email, 28 | proposal_url: delegation.proposal_url, 29 | organization_id: delegation.organization_id, 30 | voting_method_name: delegation.voting_method.name 31 | ] 32 | end 33 | 34 | test "with participant emails", context do 35 | query = """ 36 | mutation { 37 | deleteDelegation(delegatorEmail: "#{context[:delegator_email]}", delegateEmail: "#{ 38 | context[:delegate_email] 39 | }", votingMethod: "#{context[:voting_method_name]}", proposalUrl: "#{context[:proposal_url]}") { 40 | proposalUrl 41 | votingResult { 42 | in_favor 43 | against 44 | } 45 | } 46 | } 47 | """ 48 | 49 | {:ok, %{data: %{"deleteDelegation" => delegation}}} = 50 | Absinthe.run(query, Schema, context: %{organization_id: context[:organization_id]}) 51 | 52 | assert delegation["proposalUrl"] == context[:proposal_url] 53 | assert delegation["votingResult"]["in_favor"] == 1 54 | assert delegation["votingResult"]["against"] == 0 55 | end 56 | 57 | test "when delegation doesn't exist", context do 58 | query = """ 59 | mutation { 60 | deleteDelegation(delegatorEmail: "random@person.com", delegateEmail: "random2@person.com", proposalUrl: "https://random.com") { 61 | proposalUrl 62 | votingResult { 63 | in_favor 64 | against 65 | } 66 | } 67 | } 68 | """ 69 | 70 | {:ok, %{errors: [%{message: message}]}} = 71 | Absinthe.run(query, Schema, context: %{organization_id: context[:organization_id]}) 72 | 73 | assert message == "No delegation found to delete" 74 | end 75 | 76 | test "updates a related voting result" do 77 | proposal_delegation = insert(:delegation_for_proposal) 78 | 79 | insert(:vote, 80 | yes: true, 81 | participant: proposal_delegation.delegate, 82 | voting_method: proposal_delegation.voting_method, 83 | proposal_url: proposal_delegation.proposal_url, 84 | organization_id: proposal_delegation.organization_id 85 | ) 86 | 87 | # Delete the proposal-specific delegation created at beginning of test. 88 | query = """ 89 | mutation { 90 | deleteDelegation(delegatorEmail: "#{proposal_delegation.delegator.email}", delegateEmail: "#{ 91 | proposal_delegation.delegate.email 92 | }", votingMethod: "#{proposal_delegation.voting_method.name}", proposalUrl: "#{ 93 | proposal_delegation.proposal_url 94 | }") { 95 | votingResult { 96 | inFavor 97 | against 98 | } 99 | } 100 | } 101 | """ 102 | 103 | {:ok, %{data: %{"deleteDelegation" => deleted_delegation}}} = 104 | Absinthe.run(query, Schema, 105 | context: %{organization_id: proposal_delegation.organization_id} 106 | ) 107 | 108 | # Assert voting result does not include vote weight of deleted delegation. 109 | assert deleted_delegation["votingResult"]["inFavor"] == 1 110 | assert deleted_delegation["votingResult"]["against"] == 0 111 | end 112 | end 113 | 114 | describe "delete global delegation" do 115 | setup do 116 | global_delegation = insert(:delegation) 117 | 118 | # proposal-specific delegation for SAME participants as global delegation (and same organization id) 119 | insert(:delegation_for_proposal, 120 | delegator: global_delegation.delegator, 121 | delegate: global_delegation.delegate, 122 | organization_id: global_delegation.organization_id 123 | ) 124 | 125 | # proposal-specific delegation for DIFFERENT participants to global delegation (and same organization id) 126 | proposal_delegation_different_participants = 127 | insert(:delegation_for_proposal, organization_id: global_delegation.organization_id) 128 | 129 | [ 130 | delegator_email: global_delegation.delegator.email, 131 | delegate_email: global_delegation.delegate.email, 132 | global_delegation_id: global_delegation.id, 133 | organization_id: global_delegation.organization_id, 134 | proposal_only_delegator_email: proposal_delegation_different_participants.delegator.email, 135 | proposal_only_delegate_email: proposal_delegation_different_participants.delegate.email 136 | ] 137 | end 138 | 139 | test "with participant emails", context do 140 | query = """ 141 | mutation { 142 | deleteDelegation(delegatorEmail: "#{context[:delegator_email]}", delegateEmail: "#{ 143 | context[:delegate_email] 144 | }") { 145 | proposalUrl 146 | id 147 | } 148 | } 149 | """ 150 | 151 | {:ok, %{data: %{"deleteDelegation" => deleted_delegation}}} = 152 | Absinthe.run(query, Schema, context: %{organization_id: context[:organization_id]}) 153 | 154 | assert deleted_delegation["proposal_url"] == nil 155 | assert deleted_delegation["id"] == context[:global_delegation_id] 156 | end 157 | 158 | # test case where a proposal-specific delegation already exists and we wish to delete an 159 | # existing global delegation for the same delegator & delegate. 160 | # NOTE: this case should never occur when we prevent global AND proposal-specific delegations 161 | # existing simultaneously for same delegator/delegate pair. 162 | test "when a similar proposal-specific delegation exists", context do 163 | query = """ 164 | mutation { 165 | deleteDelegation(delegatorEmail: "#{context[:delegator_email]}", delegateEmail: "#{ 166 | context[:delegate_email] 167 | }") { 168 | id 169 | } 170 | } 171 | """ 172 | 173 | {:ok, %{data: %{"deleteDelegation" => deleted_delegation}}} = 174 | Absinthe.run(query, Schema, context: %{organization_id: context[:organization_id]}) 175 | 176 | assert deleted_delegation["id"] == context[:global_delegation_id] 177 | end 178 | 179 | # test case where a proposal-specific delegation already exists and we try to delete a 180 | # non-existent global delegation for the same delegator & delegate. 181 | test "when no matching global delegation exists, but a similar proposal-specific delegation exists", 182 | context do 183 | query = """ 184 | mutation { 185 | deleteDelegation(delegatorEmail: "#{context[:proposal_only_delegator_email]}", delegateEmail: "#{ 186 | context[:proposal_only_delegate_email] 187 | }") { 188 | proposalUrl 189 | } 190 | } 191 | """ 192 | 193 | {:ok, %{errors: [%{message: message}]}} = 194 | Absinthe.run(query, Schema, context: %{organization_id: context[:organization_id]}) 195 | 196 | assert message == "No delegation found to delete" 197 | end 198 | 199 | test "when delegation doesn't exist", context do 200 | query = """ 201 | mutation { 202 | deleteDelegation(delegatorEmail: "random@person.com", delegateEmail: "random2@person.com") { 203 | proposalUrl 204 | } 205 | } 206 | """ 207 | 208 | {:ok, %{errors: [%{message: message}]}} = 209 | Absinthe.run(query, Schema, context: %{organization_id: context[:organization_id]}) 210 | 211 | assert message == "No delegation found to delete" 212 | end 213 | 214 | test "updates a related voting result" do 215 | global_delegation = insert(:delegation) 216 | 217 | vote = 218 | insert(:vote, 219 | yes: true, 220 | participant: global_delegation.delegate, 221 | organization_id: global_delegation.organization_id 222 | ) 223 | 224 | # Delete the global delegation which was created at beginning of test. 225 | query = """ 226 | mutation { 227 | deleteDelegation(delegatorEmail: "#{global_delegation.delegator.email}", delegateEmail: "#{ 228 | global_delegation.delegate.email 229 | }") { 230 | id 231 | } 232 | } 233 | """ 234 | 235 | {:ok, _} = 236 | Absinthe.run(query, Schema, context: %{organization_id: global_delegation.organization_id}) 237 | 238 | # Query the voting result for the vote proposal. 239 | query = """ 240 | query { 241 | votingResult(votingMethod: "#{vote.voting_method.name}", proposalUrl: "#{ 242 | vote.proposal_url 243 | }") { 244 | inFavor 245 | against 246 | proposalUrl 247 | } 248 | } 249 | """ 250 | 251 | {:ok, %{data: %{"votingResult" => result}}} = 252 | Absinthe.run(query, Schema, context: %{organization_id: global_delegation.organization_id}) 253 | 254 | # Assert voting result does not include vote weight of deleted delegation. 255 | assert result["proposalUrl"] == vote.proposal_url 256 | assert result["against"] == 0 257 | assert result["inFavor"] == 1 258 | end 259 | end 260 | end 261 | -------------------------------------------------------------------------------- /test/liquid_voting_web/absinthe/mutations/delete_vote_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVotingWeb.Absinthe.Mutations.DeleteVoteTest do 2 | use LiquidVotingWeb.ConnCase 3 | import LiquidVoting.Factory 4 | 5 | alias LiquidVotingWeb.Schema.Schema 6 | 7 | describe "delete vote" do 8 | setup do 9 | voting_method = insert(:voting_method) 10 | 11 | vote = 12 | insert(:vote, voting_method: voting_method, organization_id: voting_method.organization_id) 13 | 14 | result = 15 | insert(:voting_result, 16 | in_favor: 1, 17 | voting_method: voting_method, 18 | proposal_url: vote.proposal_url 19 | ) 20 | 21 | [ 22 | participant_email: vote.participant.email, 23 | voting_method_name: voting_method.name, 24 | proposal_url: vote.proposal_url, 25 | organization_id: vote.organization_id, 26 | result: result 27 | ] 28 | end 29 | 30 | test "with a participant's email and proposal_url", context do 31 | query = """ 32 | mutation { 33 | deleteVote(participantEmail: "#{context[:participant_email]}", votingMethod: "#{ 34 | context[:voting_method_name] 35 | }", proposalUrl: "#{context[:proposal_url]}") { 36 | participant { 37 | email 38 | } 39 | votingResult { 40 | in_favor 41 | against 42 | } 43 | } 44 | } 45 | """ 46 | 47 | {:ok, %{data: %{"deleteVote" => vote}}} = 48 | Absinthe.run(query, Schema, context: %{organization_id: context[:organization_id]}) 49 | 50 | assert vote["participant"]["email"] == context[:participant_email] 51 | assert vote["votingResult"]["in_favor"] == 0 52 | assert vote["votingResult"]["against"] == 0 53 | end 54 | 55 | test "when vote doesn't exist", context do 56 | another_participant = insert(:participant, organization_id: context[:organization_id]) 57 | 58 | query = """ 59 | mutation { 60 | deleteVote(participantEmail: "#{another_participant.email}", votingMethod: "#{ 61 | context[:voting_method_name] 62 | }", proposalUrl: "#{context[:proposal_url]}") { 63 | participant { 64 | email 65 | } 66 | } 67 | } 68 | """ 69 | 70 | {:ok, %{errors: [%{message: message}]}} = 71 | Absinthe.run(query, Schema, context: %{organization_id: context[:organization_id]}) 72 | 73 | assert message == "No vote found to delete" 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/liquid_voting_web/absinthe/queries/delegations_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVotingWeb.Absinthe.Queries.DelegationsTest do 2 | use LiquidVotingWeb.ConnCase 3 | import LiquidVoting.Factory 4 | 5 | alias LiquidVotingWeb.Schema.Schema 6 | 7 | describe "query delegations" do 8 | test "without a proposal url" do 9 | delegation = insert(:delegation) 10 | organization_id = delegation.organization_id 11 | 12 | query = """ 13 | query { 14 | delegations { 15 | delegate { 16 | email 17 | } 18 | delegator { 19 | email 20 | } 21 | votingResult { 22 | in_favor 23 | against 24 | proposalUrl 25 | } 26 | } 27 | } 28 | """ 29 | 30 | {:ok, %{data: %{"delegations" => [payload | _]}}} = 31 | Absinthe.run(query, Schema, context: %{organization_id: organization_id}) 32 | 33 | assert payload["delegate"]["email"] == delegation.delegate.email 34 | assert payload["delegator"]["email"] == delegation.delegator.email 35 | assert payload["votingResult"] == nil 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/liquid_voting_web/absinthe/queries/votes_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVotingWeb.Absinthe.Queries.VotesTest do 2 | use LiquidVotingWeb.ConnCase 3 | import LiquidVoting.Factory 4 | 5 | alias LiquidVotingWeb.Schema.Schema 6 | 7 | describe "query votes" do 8 | setup do 9 | org_id = Ecto.UUID.generate() 10 | # We create 3 votes for the same organization. 11 | # Including, 2 votes for the same proposal url and voting method: 12 | voting_method_A = insert(:voting_method, name: "methodA", organization_id: org_id) 13 | 14 | insert(:vote, 15 | proposal_url: "https://proposals/p1", 16 | voting_method: voting_method_A, 17 | organization_id: org_id 18 | ) 19 | 20 | insert(:vote, 21 | proposal_url: "https://proposals/p1", 22 | voting_method: voting_method_A, 23 | organization_id: org_id 24 | ) 25 | 26 | # ... and 1 vote for the same proposal url, but "default" voting method: 27 | voting_method_default = insert(:voting_method, name: "default", organization_id: org_id) 28 | 29 | insert(:vote, 30 | proposal_url: "https://proposals/p1", 31 | voting_method: voting_method_default, 32 | organization_id: org_id 33 | ) 34 | 35 | # We create 1 vote for a different organization: 36 | insert(:vote, 37 | proposal_url: "https://proposals/p1", 38 | voting_method: voting_method_A, 39 | organization_id: Ecto.UUID.generate() 40 | ) 41 | 42 | [ 43 | organization_id: org_id 44 | ] 45 | end 46 | 47 | test "without a proposal url or voting_method", context do 48 | query = """ 49 | query { 50 | votes { 51 | id 52 | proposalUrl 53 | votingMethod{ 54 | name 55 | } 56 | } 57 | } 58 | """ 59 | 60 | {:ok, %{data: %{"votes" => result_payload}}} = 61 | Absinthe.run(query, Schema, context: %{organization_id: context[:organization_id]}) 62 | 63 | # Assert we retrieve results for 3 votes for same organization, created in setup, 64 | # and not 4 (excluding the vote created in setup for a different organization). 65 | assert Enum.count(result_payload) == 3 66 | end 67 | 68 | test "for a proposal url and voting_method", context do 69 | query = """ 70 | query { 71 | votes(votingMethod: "methodA", proposal_url: "https://proposals/p1") { 72 | id 73 | proposalUrl 74 | votingMethod{ 75 | name 76 | } 77 | } 78 | } 79 | """ 80 | 81 | {:ok, %{data: %{"votes" => result_payload}}} = 82 | Absinthe.run(query, Schema, context: %{organization_id: context[:organization_id]}) 83 | 84 | # Assert we retrieve results for 2 votes for same voting method. 85 | assert Enum.count(result_payload) == 2 86 | # Assert both votes are for the correct voting method. 87 | assert Enum.at(result_payload, 0)["votingMethod"]["name"] == "methodA" 88 | assert Enum.at(result_payload, 1)["votingMethod"]["name"] == "methodA" 89 | end 90 | 91 | test "for a proposal url only", context do 92 | query = """ 93 | query { 94 | votes(proposal_url: "https://proposals/p1") { 95 | id 96 | proposalUrl 97 | votingMethod{ 98 | name 99 | } 100 | } 101 | } 102 | """ 103 | 104 | {:ok, %{data: %{"votes" => result_payload}}} = 105 | Absinthe.run(query, Schema, context: %{organization_id: context[:organization_id]}) 106 | 107 | # Assert we retrieve results for 1 vote 108 | assert Enum.count(result_payload) == 1 109 | # Assert the vote is for the "default" voting method. 110 | assert Enum.at(result_payload, 0)["votingMethod"]["name"] == "default" 111 | end 112 | 113 | test "for a non-existent proposal url", context do 114 | query = """ 115 | query { 116 | votes(proposal_url: "https://proposals/non-existent") { 117 | id 118 | proposalUrl 119 | votingMethod{ 120 | name 121 | } 122 | } 123 | } 124 | """ 125 | 126 | {:ok, %{data: %{"votes" => result_payload}}} = 127 | Absinthe.run(query, Schema, context: %{organization_id: context[:organization_id]}) 128 | 129 | IO.inspect(result_payload) 130 | 131 | # Assert we retrieve an empty list 132 | assert result_payload == [] 133 | end 134 | 135 | test "for a valid proposal url and non-existent voting method", context do 136 | query = """ 137 | query { 138 | votes(votingMethod: "non-existent-method", proposal_url: "https://proposals/p1") { 139 | id 140 | proposalUrl 141 | votingMethod{ 142 | name 143 | } 144 | } 145 | } 146 | """ 147 | 148 | {:ok, %{data: %{"votes" => result_payload}}} = 149 | Absinthe.run(query, Schema, context: %{organization_id: context[:organization_id]}) 150 | 151 | # Assert we retrieve an empty list 152 | assert result_payload == [] 153 | end 154 | 155 | test "for a voting method, with no proposal url specified", context do 156 | query = """ 157 | query { 158 | votes(votingMethod: "methodA") { 159 | id 160 | proposalUrl 161 | votingMethod{ 162 | name 163 | } 164 | } 165 | } 166 | """ 167 | 168 | {:ok, %{errors: [%{message: message}]}} = 169 | Absinthe.run(query, Schema, context: %{organization_id: context[:organization_id]}) 170 | 171 | # Assert we receive the correct error message. 172 | assert message == "A proposal url must also be given when a voting method is specified" 173 | end 174 | end 175 | end 176 | -------------------------------------------------------------------------------- /test/liquid_voting_web/absinthe/queries/voting_result_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVotingWeb.Absinthe.Queries.VotingResultTest do 2 | use LiquidVotingWeb.ConnCase 3 | import LiquidVoting.Factory 4 | 5 | alias LiquidVotingWeb.Schema.Schema 6 | 7 | describe "query voting result" do 8 | test "for a given proposal url and voting_method name" do 9 | result = insert(:voting_result) 10 | 11 | query = """ 12 | query { 13 | votingResult(votingMethod: "#{result.voting_method.name}", proposalUrl: "#{ 14 | result.proposal_url 15 | }") { 16 | in_favor 17 | against 18 | proposalUrl 19 | } 20 | } 21 | """ 22 | 23 | {:ok, %{data: %{"votingResult" => result_payload}}} = 24 | Absinthe.run(query, Schema, context: %{organization_id: result.organization_id}) 25 | 26 | assert result_payload["in_favor"] == result.in_favor 27 | assert result_payload["against"] == result.against 28 | assert result_payload["proposalUrl"] == result.proposal_url 29 | end 30 | 31 | test "for a given proposal url, without voting_method name, when a 'default' voting method exists" do 32 | voting_method = insert(:voting_method, name: "default") 33 | 34 | result = 35 | insert(:voting_result, 36 | voting_method: voting_method, 37 | organization_id: voting_method.organization_id 38 | ) 39 | 40 | query = """ 41 | query { 42 | votingResult(proposalUrl: "#{result.proposal_url}") { 43 | in_favor 44 | against 45 | proposalUrl 46 | } 47 | } 48 | """ 49 | 50 | {:ok, %{data: %{"votingResult" => result_payload}}} = 51 | Absinthe.run(query, Schema, context: %{organization_id: result.organization_id}) 52 | 53 | assert result_payload["in_favor"] == result.in_favor 54 | assert result_payload["against"] == result.against 55 | assert result_payload["proposalUrl"] == result.proposal_url 56 | end 57 | 58 | test "for a given proposal url, without voting_method name, when no 'default' voting method exists" do 59 | voting_method = insert(:voting_method, name: "specific_voting_method") 60 | 61 | result = 62 | insert(:voting_result, 63 | voting_method: voting_method, 64 | organization_id: voting_method.organization_id 65 | ) 66 | 67 | query = """ 68 | query { 69 | votingResult(proposalUrl: "#{result.proposal_url}") { 70 | in_favor 71 | against 72 | proposalUrl 73 | } 74 | } 75 | """ 76 | 77 | {:ok, %{errors: [%{message: message}]}} = 78 | Absinthe.run(query, Schema, context: %{organization_id: result.organization_id}) 79 | 80 | assert message == "No matching result found" 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /test/liquid_voting_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiquidVotingWeb.ErrorViewTest do 2 | use LiquidVotingWeb.ConnCase, async: true 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | import Phoenix.View 6 | 7 | test "renders 404.json" do 8 | assert render(LiquidVotingWeb.ErrorView, "404.json", []) == %{errors: %{detail: "Not Found"}} 9 | end 10 | 11 | test "renders 500.json" do 12 | assert render(LiquidVotingWeb.ErrorView, "500.json", []) == 13 | %{errors: %{detail: "Internal Server Error"}} 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule LiquidVotingWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with channels 21 | use Phoenix.ChannelTest 22 | 23 | # The default endpoint for testing 24 | @endpoint LiquidVotingWeb.Endpoint 25 | end 26 | end 27 | 28 | setup tags do 29 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(LiquidVoting.Repo) 30 | 31 | unless tags[:async] do 32 | Ecto.Adapters.SQL.Sandbox.mode(LiquidVoting.Repo, {:shared, self()}) 33 | end 34 | 35 | :ok 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule LiquidVotingWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with connections 21 | import Plug.Conn 22 | import Phoenix.ConnTest 23 | 24 | alias LiquidVotingWeb.Router.Helpers, as: Routes 25 | 26 | # The default endpoint for testing 27 | @endpoint LiquidVotingWeb.Endpoint 28 | end 29 | end 30 | 31 | setup tags do 32 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(LiquidVoting.Repo) 33 | 34 | unless tags[:async] do 35 | Ecto.Adapters.SQL.Sandbox.mode(LiquidVoting.Repo, {:shared, self()}) 36 | end 37 | 38 | {:ok, conn: Phoenix.ConnTest.build_conn()} 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 8 | 9 | Finally, if the test case interacts with the database, 10 | it cannot be async. For this reason, every test runs 11 | inside a transaction which is reset at the beginning 12 | of the test unless the test case is marked as async. 13 | """ 14 | 15 | use ExUnit.CaseTemplate 16 | 17 | using do 18 | quote do 19 | alias LiquidVoting.Repo 20 | 21 | import Ecto 22 | import Ecto.Changeset 23 | import Ecto.Query 24 | import LiquidVoting.DataCase 25 | end 26 | end 27 | 28 | setup tags do 29 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(LiquidVoting.Repo) 30 | 31 | unless tags[:async] do 32 | Ecto.Adapters.SQL.Sandbox.mode(LiquidVoting.Repo, {:shared, self()}) 33 | end 34 | 35 | :ok 36 | end 37 | 38 | @doc """ 39 | A helper that transforms changeset errors into a map of messages. 40 | 41 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 42 | assert "password is too short" in errors_on(changeset).password 43 | assert %{password: ["password is too short"]} = errors_on(changeset) 44 | 45 | """ 46 | def errors_on(changeset) do 47 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 48 | Regex.replace(~r"%{(\w+)}", message, fn _, key -> 49 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 50 | end) 51 | end) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/support/factory.ex: -------------------------------------------------------------------------------- 1 | defmodule LiquidVoting.Factory do 2 | use ExMachina.Ecto, repo: LiquidVoting.Repo 3 | 4 | alias LiquidVoting.Voting.{Vote, Participant} 5 | alias LiquidVoting.Delegations.Delegation 6 | alias LiquidVoting.VotingResults.Result 7 | alias LiquidVoting.VotingMethods.VotingMethod 8 | 9 | def participant_factory(attrs) do 10 | organization_id = Map.get(attrs, :organization_id, Ecto.UUID.generate()) 11 | 12 | participant = %Participant{ 13 | name: sequence(:name, &"Jane Doe #{&1}"), 14 | email: sequence(:email, &"jane#{&1}@somedomain.com"), 15 | organization_id: organization_id 16 | } 17 | 18 | merge_attributes(participant, attrs) 19 | end 20 | 21 | def vote_factory(attrs) do 22 | organization_id = Map.get(attrs, :organization_id, Ecto.UUID.generate()) 23 | 24 | voting_method_name = 25 | Map.get(attrs, :voting_method_name, sequence(:name, &"voting-method-name#{&1}")) 26 | 27 | voting_method = 28 | Map.get( 29 | attrs, 30 | :voting_method, 31 | build(:voting_method, organization_id: organization_id, name: voting_method_name) 32 | ) 33 | 34 | vote = %Vote{ 35 | yes: true, 36 | proposal_url: sequence(:proposal_url, &"https://proposals.com/#{&1}"), 37 | participant: build(:participant, organization_id: organization_id), 38 | voting_method: voting_method, 39 | organization_id: organization_id 40 | } 41 | 42 | merge_attributes(vote, attrs) 43 | end 44 | 45 | def delegation_factory(attrs) do 46 | organization_id = Map.get(attrs, :organization_id, Ecto.UUID.generate()) 47 | 48 | delegation = %Delegation{ 49 | delegator: build(:participant, organization_id: organization_id), 50 | delegate: build(:participant, organization_id: organization_id), 51 | organization_id: organization_id 52 | } 53 | 54 | merge_attributes(delegation, attrs) 55 | end 56 | 57 | def delegation_for_proposal_factory(attrs) do 58 | organization_id = Map.get(attrs, :organization_id, Ecto.UUID.generate()) 59 | 60 | voting_method_name = 61 | Map.get(attrs, :voting_method_name, sequence(:name, &"voting-method-name#{&1}")) 62 | 63 | voting_method = 64 | Map.get( 65 | attrs, 66 | :voting_method, 67 | build(:voting_method, organization_id: organization_id, name: voting_method_name) 68 | ) 69 | 70 | delegation = %Delegation{ 71 | delegator: build(:participant, organization_id: organization_id), 72 | delegate: build(:participant, organization_id: organization_id), 73 | voting_method: voting_method, 74 | proposal_url: sequence(:proposal_url, &"https://proposals.com/#{&1}"), 75 | organization_id: organization_id 76 | } 77 | 78 | merge_attributes(delegation, attrs) 79 | end 80 | 81 | def voting_result_factory(attrs) do 82 | organization_id = Map.get(attrs, :organization_id, Ecto.UUID.generate()) 83 | 84 | voting_method_name = 85 | Map.get(attrs, :voting_method_name, sequence(:name, &"voting-method-name#{&1}")) 86 | 87 | voting_method = 88 | Map.get( 89 | attrs, 90 | :voting_method, 91 | build(:voting_method, organization_id: organization_id, name: voting_method_name) 92 | ) 93 | 94 | voting_result = %Result{ 95 | in_favor: 0, 96 | against: 0, 97 | voting_method: voting_method, 98 | proposal_url: sequence(:proposal_url, &"https://proposals.com/#{&1}"), 99 | organization_id: organization_id 100 | } 101 | 102 | merge_attributes(voting_result, attrs) 103 | end 104 | 105 | def voting_method_factory(attrs) do 106 | organization_id = Map.get(attrs, :organization_id, Ecto.UUID.generate()) 107 | name = Map.get(attrs, :name, sequence(:name, &"voting-method-name#{&1}")) 108 | 109 | voting_method = %VotingMethod{ 110 | name: name, 111 | organization_id: organization_id 112 | } 113 | 114 | merge_attributes(voting_method, attrs) 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | {:ok, _} = Application.ensure_all_started(:ex_machina) 2 | ExUnit.start() 3 | Ecto.Adapters.SQL.Sandbox.mode(LiquidVoting.Repo, :manual) 4 | --------------------------------------------------------------------------------