From 4c46b39c51f44dfe264f6ca58505f18497ddc2a1 Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Thu, 25 Jul 2024 10:22:08 -0400 Subject: [PATCH] refactor: combine `Ballots` and `Votes` contexts into a single `RankedVoting` context (#20) --- docs/decisions/1-timestamps.md | 35 +- .../2-ballot-and-vote-schema-shape.md | 31 +- lib/flick/{ballots.ex => ranked_voting.ex} | 68 ++-- .../{ballots => ranked_voting}/ballot.ex | 8 +- .../{votes => ranked_voting}/ranked_answer.ex | 2 +- lib/flick/{votes => ranked_voting}/vote.ex | 12 +- lib/flick/votes.ex | 45 --- lib/flick_web/live/ballots/editor_live.ex | 16 +- lib/flick_web/live/ballots/index_live.ex | 4 +- lib/flick_web/live/ballots/viewer_live.ex | 8 +- lib/flick_web/live/vote/vote_capture_live.ex | 19 +- priv/repo/seeds.exs | 4 +- test/flick/ballots_test.exs | 175 ---------- test/flick/ranked_voting_test.exs | 304 ++++++++++++++++++ test/flick/votes_test.exs | 129 -------- test/support/fixtures/ballot_fixture.ex | 8 +- 16 files changed, 444 insertions(+), 424 deletions(-) rename lib/flick/{ballots.ex => ranked_voting.ex} (50%) rename lib/flick/{ballots => ranked_voting}/ballot.ex (91%) rename lib/flick/{votes => ranked_voting}/ranked_answer.ex (92%) rename lib/flick/{votes => ranked_voting}/vote.ex (93%) delete mode 100644 lib/flick/votes.ex delete mode 100644 test/flick/ballots_test.exs create mode 100644 test/flick/ranked_voting_test.exs delete mode 100644 test/flick/votes_test.exs diff --git a/docs/decisions/1-timestamps.md b/docs/decisions/1-timestamps.md index 3283b71..ee5f490 100644 --- a/docs/decisions/1-timestamps.md +++ b/docs/decisions/1-timestamps.md @@ -2,28 +2,47 @@ # Problem Statement -Out of the box, when you use the Ecto [`timestamps/1` function][1], you end up with `:naive_datetime` values for in-memory entities. This lacks the level of timezone precision we would prefer. +Out of the box, when you use the Ecto [`timestamps/1` function][1], you end up +with `:naive_datetime` values for in-memory entities. This lacks the level of +timezone precision we would prefer. [1]: https://hexdocs.pm/ecto/Ecto.Schema.html#timestamps/1 ## Solution -Our upcoming Ecto schemas will use `timestamps(type::utc_datetime_usec)` to be explicit about UTC and use microseconds for more precision. +Our upcoming Ecto schemas will use `timestamps(type::utc_datetime_usec)` to be +explicit about UTC and use microseconds for more precision. -When creating the database columns in our migration files, we will also use `timestamps(type::utc_datetime_usec)`. This results in the database column having a type like `timestamp without time zone NOT NULL`. +When creating the database columns in our migration files, we will also use +`timestamps(type::utc_datetime_usec)`. This results in the database column +having a type like `timestamp without time zone NOT NULL`. -Take note this Postgres column type does not have any timezone information. We assume the database will always store timestamp values in UTC (which is a community norm). +Take note this Postgres column type does not have any timezone information. We +assume the database will always store timestamp values in UTC (which is a +community norm). ## Other Solutions Considered ### `timestamptz` -We could have used a database migration style with `timestamps(type: :timestamptz)`, which would store timezone information in the Postgres database, **but** that also encourages people to store non-UTC timestamps in the database. For clarity, we would prefer the database always be UTC. +We could have used a database migration style with `timestamps(type: +:timestamptz)`, which would store timezone information in the Postgres database, +**but** that also encourages people to store non-UTC timestamps in the database. +For clarity, we would prefer the database always be UTC. This is not an irreversible decision and can be adjusted if wanted. ### What are timestamps? -An argument can be made that the timestamp columns of the database are metadata of the implementation and should not be viewed as domain-specific values. The logic goes: If you want to track when your domain entities are created with your specific domain perspective, you should have `created_at` and `edited_at` columns. Those database-specific `inserted_at` and `updated_at` columns may change due to implementation needs and not accurately represent the actual domain knowledge. - -That said, for the sake of simplicity, we are **not** going to introduce `created_at` and `edited_at` and will continue to make the `inserted_at` and `updated_at` values available to the Elixir code. Should these columns deviate from the domain interpretation, we can add those other columns later. +An argument can be made that the timestamp columns of the database are metadata +of the implementation and should not be viewed as domain-specific values. The +logic goes: If you want to track when your domain entities are created with your +specific domain perspective, you should have `created_at` and `edited_at` +columns. Those database-specific `inserted_at` and `updated_at` columns may +change due to implementation needs and not accurately represent the actual +domain knowledge. + +That said, for the sake of simplicity, we are **not** going to introduce +`created_at` and `edited_at` and will continue to make the `inserted_at` and +`updated_at` values available to the Elixir code. Should these columns deviate +from the domain interpretation, we can add those other columns later. diff --git a/docs/decisions/2-ballot-and-vote-schema-shape.md b/docs/decisions/2-ballot-and-vote-schema-shape.md index 2f7111a..c4018d1 100644 --- a/docs/decisions/2-ballot-and-vote-schema-shape.md +++ b/docs/decisions/2-ballot-and-vote-schema-shape.md @@ -2,20 +2,35 @@ ## Ballots -The `Flick.Ballots.Ballot` schema captures a simple one-question ballot using fields for `question_title` and `possible_answers`. +The `Flick.RankedVoting.Ballot` schema captures a simple one-question ballot +using fields for `question_title` and `possible_answers`. -We could have solved "possible answers" in the data model in multiple ways but have picked this specific style with intent. +We could have solved "possible answers" in the data model in multiple ways but +have picked this specific style with intent. -We expect `possible_answers` to be a comma-separated list of values and have some basic validations around this. This decision was made with frontend practicality in mind. We've simplified the frontend UI by eliminating the need for a second layer of `inputs_for` for an alternative `PossibleAnswer` `embeds_many` schema design. +We expect `possible_answers` to be a comma-separated list of values and have +some basic validations around this. This decision was made with frontend +practicality in mind. We've simplified the frontend UI by eliminating the need +for a second layer of `inputs_for` for an alternative `PossibleAnswer` +`embeds_many` schema design. -Additionally, early drafts of this schema allowed a ballot to have multiple questions, but again, we've chosen to simplify for now. +Additionally, early drafts of this schema allowed a ballot to have multiple +questions, but again, we've chosen to simplify for now. ## Votes -When capturing votes, one might imagine a vote to have a `ballot_id` and then a list of ordered `answer_id` values. Given the above-described storage of `possible_answers`, we don't have answer id values. +When capturing votes, one might imagine a vote to have a `ballot_id` and then a +list of ordered `answer_id` values. Given the above-described storage of +`possible_answers`, we don't have answer id values. -When we capture a vote, we store an answer as a string value, the same string defined in the possible answer. +When we capture a vote, we store an answer as a string value, the same string +defined in the possible answer. -This will duplicate the answer strings many times in the database; however, given the expected early usage of this app and the fact that a UUID value can cost as much as many of these smaller answer values, this is an acceptable tradeoff for now. If we want to refactor, we can do so in the future. +This will duplicate the answer strings many times in the database; however, +given the expected early usage of this app and the fact that a UUID value can +cost as much as many of these smaller answer values, this is an acceptable +tradeoff for now. If we want to refactor, we can do so in the future. -To prevent recorded votes from becoming misaligned with edited answers, we'll introduce a publish event for a ballot, [making it non-editable by users](https://github.com/zorn/flick/issues/13). +To prevent recorded votes from becoming misaligned with edited answers, we'll +introduce a publish event for a ballot, [making it non-editable by +users](https://github.com/zorn/flick/issues/13). diff --git a/lib/flick/ballots.ex b/lib/flick/ranked_voting.ex similarity index 50% rename from lib/flick/ballots.ex rename to lib/flick/ranked_voting.ex index 203125d..40d45a2 100644 --- a/lib/flick/ballots.ex +++ b/lib/flick/ranked_voting.ex @@ -1,17 +1,16 @@ -defmodule Flick.Ballots do +defmodule Flick.RankedVoting do @moduledoc """ - Provides functions related to managing `Flick.Ballots.Ballot` entities. + Provides functions related to managing `Flick.RankedVoting.Ballot` entities. """ - alias Flick.Ballots.Ballot + alias Flick.RankedVoting.Ballot + alias Flick.RankedVoting.Vote alias Flick.Repo - @typep changeset :: Ecto.Changeset.t(Ballot.t()) - @doc """ - Creates a new `Flick.Ballots.Ballot` entity with the given `title` and `questions`. + Creates a new `Flick.RankedVoting.Ballot` entity with the given `title` and `questions`. """ - @spec create_ballot(map()) :: {:ok, Ballot.t()} | {:error, changeset()} + @spec create_ballot(map()) :: {:ok, Ballot.t()} | {:error, Ecto.Changeset.t(Ballot.t())} def create_ballot(attrs) when is_map(attrs) do %Ballot{} |> change_ballot(attrs) @@ -19,13 +18,13 @@ defmodule Flick.Ballots do end @doc """ - Updates the given `Flick.Ballots.Ballot` entity with the given attributes. + Updates the given `Flick.RankedVoting.Ballot` entity with the given attributes. - If the `Flick.Ballots.Ballot` has already been published, an error is returned. + If the `Flick.RankedVoting.Ballot` has already been published, an error is returned. """ @spec update_ballot(Ballot.t(), map()) :: {:ok, Ballot.t()} - | {:error, changeset()} + | {:error, Ecto.Changeset.t(Ballot.t())} | {:error, :can_not_update_published_ballot} def update_ballot(%Ballot{published_at: published_at}, _attrs) when not is_nil(published_at) do @@ -39,14 +38,14 @@ defmodule Flick.Ballots do end @doc """ - Publishes the given `Flick.Ballots.Ballot` entity. + Publishes the given `Flick.RankedVoting.Ballot` entity. - Once a `Flick.Ballots.Ballot` entity is published, it can no longer be updated. + Once a `Flick.RankedVoting.Ballot` entity is published, it can no longer be updated. Only a published ballot can be voted on. """ @spec publish_ballot(Ballot.t(), DateTime.t()) :: {:ok, Ballot.t()} - | {:error, changeset()} + | {:error, Ecto.Changeset.t(Ballot.t())} | {:error, :ballot_already_published} def publish_ballot(ballot, published_at \\ DateTime.utc_now()) @@ -62,7 +61,7 @@ defmodule Flick.Ballots do end @doc """ - Returns a list of all `Flick.Ballots.Ballot` entities. + Returns a list of all `Flick.RankedVoting.Ballot` entities. """ @spec list_ballots() :: [Ballot.t()] def list_ballots() do @@ -73,7 +72,7 @@ defmodule Flick.Ballots do end @doc """ - Returns a `Flick.Ballots.Ballot` entity for the given id. + Returns a `Flick.RankedVoting.Ballot` entity for the given id. Raises `Ecto.NoResultsError` if no entity was found. """ @@ -83,7 +82,7 @@ defmodule Flick.Ballots do end @doc """ - Fetches a `Flick.Ballots.Ballot` entity for the given id. + Fetches a `Flick.RankedVoting.Ballot` entity for the given id. """ @spec fetch_ballot(Ballot.id()) :: {:ok, Ballot.t()} | {:error, :ballot_not_found} def fetch_ballot(ballot_id) do @@ -94,10 +93,43 @@ defmodule Flick.Ballots do end @doc """ - Returns an `Ecto.Changeset` representing changes to a `Flick.Ballots.Ballot` entity. + Returns an `Ecto.Changeset` representing changes to a `Flick.RankedVoting.Ballot` entity. """ - @spec change_ballot(Ballot.t() | Ballot.struct_t(), map()) :: changeset() + @spec change_ballot(Ballot.t() | Ballot.struct_t(), map()) :: Ecto.Changeset.t(Ballot.t()) def change_ballot(%Ballot{} = ballot, attrs) do Ballot.changeset(ballot, attrs) end + + @doc """ + Records a vote for the given `Flick.RankedVoting.Ballot` entity. + """ + @spec record_vote(Ballot.t(), map()) :: {:ok, Vote.t()} | {:error, Ecto.Changeset.t(Vote.t())} + def record_vote(ballot, attrs) do + attrs = Map.put(attrs, "ballot_id", ballot.id) + + %Vote{} + |> Vote.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Returns an `Ecto.Changeset` representing changes to a `Flick.RankedVoting.Vote` + entity. + + ## Options + + * `:action` - An optional atom applied to the changeset, useful for forms that + look to a changeset's action to influence form behavior. + """ + @spec change_vote(Vote.t() | Vote.struct_t(), map()) :: Ecto.Changeset.t(Vote.t()) + def change_vote(%Vote{} = vote, attrs, opts \\ []) do + opts = Keyword.validate!(opts, action: nil) + changeset = Vote.changeset(vote, attrs) + + if opts[:action] do + Map.put(changeset, :action, opts[:action]) + else + changeset + end + end end diff --git a/lib/flick/ballots/ballot.ex b/lib/flick/ranked_voting/ballot.ex similarity index 91% rename from lib/flick/ballots/ballot.ex rename to lib/flick/ranked_voting/ballot.ex index 521c28c..f01479d 100644 --- a/lib/flick/ballots/ballot.ex +++ b/lib/flick/ranked_voting/ballot.ex @@ -1,4 +1,4 @@ -defmodule Flick.Ballots.Ballot do +defmodule Flick.RankedVoting.Ballot do @moduledoc """ A prompt that will be presented to the user, asking them to provide a ranked vote of answers to help make a group decision. @@ -14,7 +14,7 @@ defmodule Flick.Ballots.Ballot do @type id :: Ecto.UUID.t() @typedoc """ - A type for a persisted `Flick.Ballots.Ballot` entity. + A type for a persisted `Flick.RankedVoting.Ballot` entity. """ @type t :: %__MODULE__{ id: Ecto.UUID.t(), @@ -24,10 +24,10 @@ defmodule Flick.Ballots.Ballot do } @typedoc """ - A type for the empty `Flick.Ballots.Ballot` struct. + A type for the empty `Flick.RankedVoting.Ballot` struct. This type is helpful when you want to typespec a function that needs to accept - a non-persisted `Flick.Ballots.Ballot` struct value. + a non-persisted `Flick.RankedVoting.Ballot` struct value. """ @type struct_t :: %__MODULE__{} diff --git a/lib/flick/votes/ranked_answer.ex b/lib/flick/ranked_voting/ranked_answer.ex similarity index 92% rename from lib/flick/votes/ranked_answer.ex rename to lib/flick/ranked_voting/ranked_answer.ex index 0f7d283..5354c67 100644 --- a/lib/flick/votes/ranked_answer.ex +++ b/lib/flick/ranked_voting/ranked_answer.ex @@ -1,4 +1,4 @@ -defmodule Flick.Votes.RankedAnswer do +defmodule Flick.RankedVoting.RankedAnswer do @moduledoc """ An embedded value that represents a ranked answer to a question of a ballot. """ diff --git a/lib/flick/votes/vote.ex b/lib/flick/ranked_voting/vote.ex similarity index 93% rename from lib/flick/votes/vote.ex rename to lib/flick/ranked_voting/vote.ex index 1aca365..8afdc8e 100644 --- a/lib/flick/votes/vote.ex +++ b/lib/flick/ranked_voting/vote.ex @@ -1,6 +1,6 @@ -defmodule Flick.Votes.Vote do +defmodule Flick.RankedVoting.Vote do @moduledoc """ - A response to a `Flick.Ballots.Ballot`. A vote contains a collection of ranked + A response to a `Flick.RankedVoting.Ballot`. A vote contains a collection of ranked answers for a ballot question. """ @@ -9,13 +9,13 @@ defmodule Flick.Votes.Vote do import Ecto.Changeset import FlickWeb.Gettext - alias Flick.Ballots.Ballot - alias Flick.Votes.RankedAnswer + alias Flick.RankedVoting.Ballot + alias Flick.RankedVoting.RankedAnswer @type id :: Ecto.UUID.t() @typedoc """ - A type for a persisted `Flick.Votes.Vote` entity. + A type for a persisted `Flick.RankedVoting.Vote` entity. """ @type t :: %__MODULE__{ id: id(), @@ -52,7 +52,7 @@ defmodule Flick.Votes.Vote do end defp validate_ranked_answers_are_present_in_ballot(changeset) do - ballot = Flick.Ballots.get_ballot!(get_field(changeset, :ballot_id)) + ballot = Flick.RankedVoting.get_ballot!(get_field(changeset, :ballot_id)) possible_answers = Ballot.possible_answers_as_list(ballot.possible_answers) ++ ["", nil] validate_change(changeset, :ranked_answers, fn :ranked_answers, new_ranked_answers -> diff --git a/lib/flick/votes.ex b/lib/flick/votes.ex deleted file mode 100644 index 78a4e1a..0000000 --- a/lib/flick/votes.ex +++ /dev/null @@ -1,45 +0,0 @@ -defmodule Flick.Votes do - @moduledoc """ - Provides functions related to capturing `Flick.Votes.Vote` entities related to - a specific `Flick.Ballots.Ballot`. - """ - - alias Flick.Ballots.Ballot - alias Flick.Repo - alias Flick.Votes.Vote - - @typep changeset :: Ecto.Changeset.t(Vote.t()) - - @doc """ - Records a vote for the given `Flick.Ballots.Ballot` entity. - """ - @spec record_vote(Ballot.t(), map()) :: {:ok, Vote.t()} | {:error, changeset()} - def record_vote(ballot, attrs) do - attrs = Map.put(attrs, "ballot_id", ballot.id) - - %Vote{} - |> Vote.changeset(attrs) - |> Repo.insert() - end - - @doc """ - Returns an `Ecto.Changeset` representing changes to a `Flick.Votes.Vote` - entity. - - ## Options - - * `:action` - An optional atom applied to the changeset, useful for forms that - look to a changeset's action to influence form behavior. - """ - @spec change_vote(Vote.t() | Vote.struct_t(), map()) :: changeset() - def change_vote(%Vote{} = vote, attrs, opts \\ []) do - opts = Keyword.validate!(opts, action: nil) - changeset = Vote.changeset(vote, attrs) - - if opts[:action] do - Map.put(changeset, :action, opts[:action]) - else - changeset - end - end -end diff --git a/lib/flick_web/live/ballots/editor_live.ex b/lib/flick_web/live/ballots/editor_live.ex index 9f44d3b..7a898ec 100644 --- a/lib/flick_web/live/ballots/editor_live.ex +++ b/lib/flick_web/live/ballots/editor_live.ex @@ -1,18 +1,18 @@ defmodule FlickWeb.Ballots.EditorLive do @moduledoc """ A live view that presents a form for the creation or editing of a - `Flick.Ballots.Ballot`. + `Flick.RankedVoting.Ballot`. """ use FlickWeb, :live_view - alias Flick.Ballots - alias Flick.Ballots.Ballot + alias Flick.RankedVoting + alias Flick.RankedVoting.Ballot @impl Phoenix.LiveView def mount(params, _session, socket) do ballot = ballot(params, socket) - form = to_form(Ballots.change_ballot(ballot, %{})) + form = to_form(RankedVoting.change_ballot(ballot, %{})) socket |> assign(:form, form) @@ -31,7 +31,7 @@ defmodule FlickWeb.Ballots.EditorLive do defp ballot(params, %{assigns: %{live_action: :edit}} = _socket) do %{"ballot_id" => ballot_id} = params - Ballots.get_ballot!(ballot_id) + RankedVoting.get_ballot!(ballot_id) end defp ballot(_params, _socket) do @@ -42,7 +42,7 @@ defmodule FlickWeb.Ballots.EditorLive do def handle_event("validate", params, socket) do %{"ballot" => ballot_params} = params %{ballot: ballot} = socket.assigns - form = to_form(Ballots.change_ballot(ballot, ballot_params)) + form = to_form(RankedVoting.change_ballot(ballot, ballot_params)) {:noreply, assign(socket, form: form)} end @@ -54,7 +54,7 @@ defmodule FlickWeb.Ballots.EditorLive do %{"ballot" => ballot_params} = params %{ballot: ballot} = socket.assigns - case Ballots.update_ballot(ballot, ballot_params) do + case RankedVoting.update_ballot(ballot, ballot_params) do {:ok, ballot} -> {:noreply, redirect(socket, to: ~p"/ballots/#{ballot}")} @@ -66,7 +66,7 @@ defmodule FlickWeb.Ballots.EditorLive do defp do_save(params, socket) do %{"ballot" => ballot_params} = params - case Ballots.create_ballot(ballot_params) do + case RankedVoting.create_ballot(ballot_params) do {:ok, ballot} -> {:noreply, redirect(socket, to: ~p"/ballots/#{ballot}")} diff --git a/lib/flick_web/live/ballots/index_live.ex b/lib/flick_web/live/ballots/index_live.ex index c2e1553..79c5a2a 100644 --- a/lib/flick_web/live/ballots/index_live.ex +++ b/lib/flick_web/live/ballots/index_live.ex @@ -1,6 +1,6 @@ defmodule FlickWeb.Ballots.IndexLive do @moduledoc """ - A live view that presents the known list of `Flick.Ballots.Ballot` entities. + A live view that presents the known list of `Flick.RankedVoting.Ballot` entities. """ # TODO: In the future we probably won't just list all ballots, but for early @@ -12,7 +12,7 @@ defmodule FlickWeb.Ballots.IndexLive do def mount(_params, _session, socket) do socket |> assign(:page_title, "Ballot Index") - |> assign(:ballots, Flick.Ballots.list_ballots()) + |> assign(:ballots, Flick.RankedVoting.list_ballots()) |> ok() end diff --git a/lib/flick_web/live/ballots/viewer_live.ex b/lib/flick_web/live/ballots/viewer_live.ex index f89601e..fbe2908 100644 --- a/lib/flick_web/live/ballots/viewer_live.ex +++ b/lib/flick_web/live/ballots/viewer_live.ex @@ -1,19 +1,19 @@ defmodule FlickWeb.Ballots.ViewerLive do @moduledoc """ - A live view that presents a the generic presentation of a `Flick.Ballots.Ballot`. + A live view that presents a the generic presentation of a `Flick.RankedVoting.Ballot`. """ # TODO: This is probably the live view we'll use for voters. We'll need another live view for ballot owners. use FlickWeb, :live_view - alias Flick.Ballots + alias Flick.RankedVoting @impl Phoenix.LiveView def mount(params, _session, socket) do %{"ballot_id" => ballot_id} = params - ballot = Ballots.get_ballot!(ballot_id) + ballot = RankedVoting.get_ballot!(ballot_id) socket |> assign(:page_title, "View Ballot: #{ballot.question_title}") @@ -25,7 +25,7 @@ defmodule FlickWeb.Ballots.ViewerLive do def handle_event("publish", _params, socket) do %{ballot: ballot} = socket.assigns - case Ballots.publish_ballot(ballot) do + case RankedVoting.publish_ballot(ballot) do {:ok, ballot} -> {:noreply, assign(socket, :ballot, ballot)} diff --git a/lib/flick_web/live/vote/vote_capture_live.ex b/lib/flick_web/live/vote/vote_capture_live.ex index ab3c5f3..8c71572 100644 --- a/lib/flick_web/live/vote/vote_capture_live.ex +++ b/lib/flick_web/live/vote/vote_capture_live.ex @@ -1,21 +1,20 @@ defmodule FlickWeb.Vote.VoteCaptureLive do @moduledoc """ A live view that presents a ranked voting form for a published - `Flick.Ballots.Ballot` entity. + `Flick.RankedVoting.Ballot` entity. """ use FlickWeb, :live_view - alias Flick.Ballots - alias Flick.Ballots.Ballot - alias Flick.Votes - alias Flick.Votes.Vote + alias Flick.RankedVoting + alias Flick.RankedVoting.Ballot + alias Flick.RankedVoting.Vote alias Phoenix.LiveView.Socket @impl Phoenix.LiveView def mount(params, _session, socket) do %{"ballot_id" => ballot_id} = params - ballot = Ballots.get_ballot!(ballot_id) + ballot = RankedVoting.get_ballot!(ballot_id) socket |> verify_ballot_is_published(ballot) @@ -35,20 +34,20 @@ defmodule FlickWeb.Vote.VoteCaptureLive do ranked_answers = Enum.map(1..ranked_answer_count, fn _ -> %{value: nil} end) vote_params = %{ballot_id: ballot.id, ranked_answers: ranked_answers} - changeset = Votes.change_vote(%Vote{}, vote_params) + changeset = RankedVoting.change_vote(%Vote{}, vote_params) assign(socket, form: to_form(changeset)) end @impl Phoenix.LiveView def handle_event("validate", %{"vote" => vote_params}, socket) do - changeset = Votes.change_vote(%Vote{}, vote_params, action: :validate) + changeset = RankedVoting.change_vote(%Vote{}, vote_params, action: :validate) {:noreply, assign(socket, form: to_form(changeset))} end def handle_event("save", %{"vote" => vote_params}, socket) do %{ballot: ballot} = socket.assigns - case Votes.record_vote(ballot, vote_params) do + case RankedVoting.record_vote(ballot, vote_params) do {:ok, _vote} -> socket |> put_flash(:info, "Vote recorded.") @@ -98,7 +97,7 @@ defmodule FlickWeb.Vote.VoteCaptureLive do end defp options(ballot) do - [nil] ++ Flick.Ballots.Ballot.possible_answers_as_list(ballot.possible_answers) + [nil] ++ Flick.RankedVoting.Ballot.possible_answers_as_list(ballot.possible_answers) end defp verify_ballot_is_published(socket, ballot) do diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 0aaa820..41e382e 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -11,13 +11,13 @@ # and so on) as they will fail if something goes wrong. {:ok, ballot} = - Flick.Ballots.create_ballot(%{ + Flick.RankedVoting.create_ballot(%{ question_title: "What is your sandwich preference?", possible_answers: "Turkey, Ham, Roast Beef" }) {:ok, _vote} = - Flick.Votes.record_vote(ballot, %{ + Flick.RankedVoting.record_vote(ballot, %{ "ranked_answers" => [ %{"value" => "Turkey"}, %{"value" => "Roast Beef"}, diff --git a/test/flick/ballots_test.exs b/test/flick/ballots_test.exs deleted file mode 100644 index 352b29f..0000000 --- a/test/flick/ballots_test.exs +++ /dev/null @@ -1,175 +0,0 @@ -defmodule Flick.BallotsTest do - use Flick.DataCase, async: true - - alias Flick.Ballots - alias Flick.Ballots.Ballot - - describe "create_ballot/1" do - test "success: creates a unpublished ballot that is retrievable from the repo" do - {:ok, %Ballot{id: id}} = - Ballots.create_ballot(%{ - question_title: "What is your favorite color?", - possible_answers: "Red, Green, Blue" - }) - - assert %Ballot{ - question_title: "What is your favorite color?", - possible_answers: "Red, Green, Blue", - published_at: nil - } = - Ballots.get_ballot!(id) - end - - test "success: can create a ballot with web payload format (string keys)" do - {:ok, %Ballot{id: id}} = - Ballots.create_ballot(%{ - "question_title" => "What is your favorite food?", - "possible_answers" => "Pizza, Tacos, Sushi" - }) - - assert %Ballot{ - question_title: "What is your favorite food?", - possible_answers: "Pizza, Tacos, Sushi", - published_at: nil - } = Ballots.get_ballot!(id) - end - - test "failure: `question_title` is required" do - empty_values = ["", nil, " "] - - for empty_value <- empty_values do - assert {:error, changeset} = Ballots.create_ballot(%{question_title: empty_value}) - assert "can't be blank" in errors_on(changeset).question_title - end - end - - test "failure: `possible_answers` is required" do - empty_values = ["", nil, " "] - - for empty_value <- empty_values do - assert {:error, changeset} = Ballots.create_ballot(%{possible_answers: empty_value}) - assert "can't be blank" in errors_on(changeset).possible_answers - end - end - - test "failure: `possible_answers` must not include empty answers" do - assert {:error, changeset} = Ballots.create_ballot(%{possible_answers: "one,,two"}) - assert "can't contain empty answers" in errors_on(changeset).possible_answers - end - - test "failure: `possible_answers` must not include new lines" do - assert {:error, changeset} = Ballots.create_ballot(%{possible_answers: "one,\ntwo"}) - assert "can't contain new lines" in errors_on(changeset).possible_answers - end - - test "failure: `possible_answers` must include at least two answers" do - assert {:error, changeset} = Ballots.create_ballot(%{possible_answers: "one"}) - assert "must contain at least two answers" in errors_on(changeset).possible_answers - end - end - - describe "update_ballot/1" do - test "success: updates a ballot title and questions" do - ballot = ballot_fixture(%{question_title: "some-title", possible_answers: "a, b, c, d"}) - ballot_id = ballot.id - - changes = %{ - "question_title" => "some-title-changed", - "possible_answers" => "a, b, c, d, e" - } - - assert {:ok, - %Ballot{ - id: ^ballot_id, - question_title: "some-title-changed", - possible_answers: "a, b, c, d, e", - published_at: nil - }} = Ballots.update_ballot(ballot, changes) - end - - test "failure: `question_title` is required" do - ballot = ballot_fixture() - empty_values = ["", nil, " "] - - for empty_value <- empty_values do - changes = %{"question_title" => empty_value} - assert {:error, changeset} = Ballots.update_ballot(ballot, changes) - assert "can't be blank" in errors_on(changeset).question_title - end - end - - test "failure: can not update a published ballot" do - ballot = ballot_fixture(%{published_at: DateTime.utc_now()}) - - assert {:error, :can_not_update_published_ballot} = - Ballots.update_ballot(ballot, %{title: "some new title"}) - end - end - - describe "publish_ballot/2" do - test "success: you can publish a non-published ballot" do - ballot = ballot_fixture(%{published_at: nil}) - published_at = DateTime.utc_now() - assert {:ok, published_ballot} = Ballots.publish_ballot(ballot, published_at) - assert %Ballot{published_at: ^published_at} = published_ballot - end - - test "failure: you can not publish a published ballot" do - ballot = ballot_fixture(%{published_at: DateTime.utc_now()}) - assert {:error, :ballot_already_published} = Ballots.publish_ballot(ballot) - end - end - - describe "list_ballots/1" do - test "success: lists ballots start with zero ballots" do - assert [] = Ballots.list_ballots() - end - - test "success: lists ballots" do - ballot_a = ballot_fixture() - ballot_b = ballot_fixture() - - assert ballots = Ballots.list_ballots() - - assert length(ballots) == 2 - assert Enum.find(ballots, &match?(^ballot_a, &1)) - assert Enum.find(ballots, &match?(^ballot_b, &1)) - end - end - - describe "get_ballot!/1" do - test "success: returns a ballot" do - %Ballot{id: id, question_title: question_title} = ballot_fixture() - assert %Ballot{id: ^id, question_title: ^question_title} = Ballots.get_ballot!(id) - end - - test "failure: raises when the ballot does not exist" do - assert_raise Ecto.NoResultsError, fn -> - Ballots.get_ballot!(Ecto.UUID.generate()) - end - end - end - - describe "fetch_ballot/1" do - test "success: returns a ballot" do - %Ballot{id: id, question_title: question_title} = ballot_fixture() - assert {:ok, %Ballot{id: ^id, question_title: ^question_title}} = Ballots.fetch_ballot(id) - end - - test "failure: returns `:not_found` when the ballot does not exist" do - assert {:error, :ballot_not_found} = Ballots.fetch_ballot(Ecto.UUID.generate()) - end - end - - describe "change_ballot/2" do - test "success: returns a changeset" do - ballot = ballot_fixture(%{question_title: "some-question-title"}) - change = %{"question_title" => "some-question-title-changed"} - - assert %Ecto.Changeset{ - changes: %{question_title: "some-question-title-changed"}, - valid?: true - } = Ballots.change_ballot(ballot, change) - end - end -end diff --git a/test/flick/ranked_voting_test.exs b/test/flick/ranked_voting_test.exs new file mode 100644 index 0000000..6a3096a --- /dev/null +++ b/test/flick/ranked_voting_test.exs @@ -0,0 +1,304 @@ +defmodule Flick.RankedVotingTest do + @moduledoc """ + Validates logic of the `Flick.RankedVoting` module. + """ + + use Flick.DataCase, async: true + + alias Flick.RankedVoting + alias Flick.RankedVoting.Ballot + alias Flick.RankedVoting.Vote + alias Flick.RankedVoting.RankedAnswer + + describe "create_ballot/1" do + test "success: creates a unpublished ballot that is retrievable from the repo" do + {:ok, %Ballot{id: id}} = + RankedVoting.create_ballot(%{ + question_title: "What is your favorite color?", + possible_answers: "Red, Green, Blue" + }) + + assert %Ballot{ + question_title: "What is your favorite color?", + possible_answers: "Red, Green, Blue", + published_at: nil + } = + RankedVoting.get_ballot!(id) + end + + test "success: can create a ballot with web payload format (string keys)" do + {:ok, %Ballot{id: id}} = + RankedVoting.create_ballot(%{ + "question_title" => "What is your favorite food?", + "possible_answers" => "Pizza, Tacos, Sushi" + }) + + assert %Ballot{ + question_title: "What is your favorite food?", + possible_answers: "Pizza, Tacos, Sushi", + published_at: nil + } = RankedVoting.get_ballot!(id) + end + + test "failure: `question_title` is required" do + empty_values = ["", nil, " "] + + for empty_value <- empty_values do + assert {:error, changeset} = RankedVoting.create_ballot(%{question_title: empty_value}) + assert "can't be blank" in errors_on(changeset).question_title + end + end + + test "failure: `possible_answers` is required" do + empty_values = ["", nil, " "] + + for empty_value <- empty_values do + assert {:error, changeset} = RankedVoting.create_ballot(%{possible_answers: empty_value}) + assert "can't be blank" in errors_on(changeset).possible_answers + end + end + + test "failure: `possible_answers` must not include empty answers" do + assert {:error, changeset} = RankedVoting.create_ballot(%{possible_answers: "one,,two"}) + assert "can't contain empty answers" in errors_on(changeset).possible_answers + end + + test "failure: `possible_answers` must not include new lines" do + assert {:error, changeset} = RankedVoting.create_ballot(%{possible_answers: "one,\ntwo"}) + assert "can't contain new lines" in errors_on(changeset).possible_answers + end + + test "failure: `possible_answers` must include at least two answers" do + assert {:error, changeset} = RankedVoting.create_ballot(%{possible_answers: "one"}) + assert "must contain at least two answers" in errors_on(changeset).possible_answers + end + end + + describe "update_ballot/1" do + test "success: updates a ballot title and questions" do + ballot = ballot_fixture(%{question_title: "some-title", possible_answers: "a, b, c, d"}) + ballot_id = ballot.id + + changes = %{ + "question_title" => "some-title-changed", + "possible_answers" => "a, b, c, d, e" + } + + assert {:ok, + %Ballot{ + id: ^ballot_id, + question_title: "some-title-changed", + possible_answers: "a, b, c, d, e", + published_at: nil + }} = RankedVoting.update_ballot(ballot, changes) + end + + test "failure: `question_title` is required" do + ballot = ballot_fixture() + empty_values = ["", nil, " "] + + for empty_value <- empty_values do + changes = %{"question_title" => empty_value} + assert {:error, changeset} = RankedVoting.update_ballot(ballot, changes) + assert "can't be blank" in errors_on(changeset).question_title + end + end + + test "failure: can not update a published ballot" do + ballot = ballot_fixture(%{published_at: DateTime.utc_now()}) + + assert {:error, :can_not_update_published_ballot} = + RankedVoting.update_ballot(ballot, %{title: "some new title"}) + end + end + + describe "publish_ballot/2" do + test "success: you can publish a non-published ballot" do + ballot = ballot_fixture(%{published_at: nil}) + published_at = DateTime.utc_now() + assert {:ok, published_ballot} = RankedVoting.publish_ballot(ballot, published_at) + assert %Ballot{published_at: ^published_at} = published_ballot + end + + test "failure: you can not publish a published ballot" do + ballot = ballot_fixture(%{published_at: DateTime.utc_now()}) + assert {:error, :ballot_already_published} = RankedVoting.publish_ballot(ballot) + end + end + + describe "list_ballots/1" do + test "success: lists ballots start with zero ballots" do + assert [] = RankedVoting.list_ballots() + end + + test "success: lists ballots" do + ballot_a = ballot_fixture() + ballot_b = ballot_fixture() + + assert ballots = RankedVoting.list_ballots() + + assert length(ballots) == 2 + assert Enum.find(ballots, &match?(^ballot_a, &1)) + assert Enum.find(ballots, &match?(^ballot_b, &1)) + end + end + + describe "get_ballot!/1" do + test "success: returns a ballot" do + %Ballot{id: id, question_title: question_title} = ballot_fixture() + assert %Ballot{id: ^id, question_title: ^question_title} = RankedVoting.get_ballot!(id) + end + + test "failure: raises when the ballot does not exist" do + assert_raise Ecto.NoResultsError, fn -> + RankedVoting.get_ballot!(Ecto.UUID.generate()) + end + end + end + + describe "fetch_ballot/1" do + test "success: returns a ballot" do + %Ballot{id: id, question_title: question_title} = ballot_fixture() + + assert {:ok, %Ballot{id: ^id, question_title: ^question_title}} = + RankedVoting.fetch_ballot(id) + end + + test "failure: returns `:not_found` when the ballot does not exist" do + assert {:error, :ballot_not_found} = RankedVoting.fetch_ballot(Ecto.UUID.generate()) + end + end + + describe "change_ballot/2" do + test "success: returns a changeset" do + ballot = ballot_fixture(%{question_title: "some-question-title"}) + change = %{"question_title" => "some-question-title-changed"} + + assert %Ecto.Changeset{ + changes: %{question_title: "some-question-title-changed"}, + valid?: true + } = RankedVoting.change_ballot(ballot, change) + end + end + + describe "record_vote/2" do + setup do + ballot = + ballot_fixture( + question_title: "What's for dinner?", + possible_answers: "Pizza, Tacos, Sushi, Burgers" + ) + + {:ok, ballot} = RankedVoting.publish_ballot(ballot) + + {:ok, published_ballot: ballot} + end + + test "success: creates a vote recording the passed in answers", ~M{published_ballot} do + published_ballot_id = published_ballot.id + + assert {:ok, vote} = + RankedVoting.record_vote(published_ballot, %{ + "ranked_answers" => [ + %{"value" => "Tacos"}, + %{"value" => "Pizza"}, + %{"value" => "Burgers"}, + %{"value" => "Sushi"} + ] + }) + + assert %Vote{ + ballot_id: ^published_ballot_id, + ranked_answers: [ + %RankedAnswer{value: "Tacos"}, + %RankedAnswer{value: "Pizza"}, + %RankedAnswer{value: "Burgers"}, + %RankedAnswer{value: "Sushi"} + ] + } = vote + end + + test "success: a vote does not need to rank every possible answer", ~M{published_ballot} do + published_ballot_id = published_ballot.id + + assert {:ok, vote} = + RankedVoting.record_vote(published_ballot, %{ + "ranked_answers" => [ + %{"value" => "Sushi"}, + %{"value" => "Pizza"}, + %{"value" => ""}, + %{"value" => ""} + ] + }) + + assert %Vote{ + ballot_id: ^published_ballot_id, + ranked_answers: [ + %RankedAnswer{value: "Sushi"}, + %RankedAnswer{value: "Pizza"}, + %RankedAnswer{value: nil}, + %RankedAnswer{value: nil} + ] + } = vote + end + + test "failure: a vote should not include an answer value that is not present in the ballot", + %{ + published_ballot: published_ballot + } do + attrs = %{ + "ranked_answers" => [ + %{"value" => "Forbidden Hot Dogs"}, + %{"value" => "Illegal Cookies"} + ] + } + + assert {:error, changeset} = RankedVoting.record_vote(published_ballot, attrs) + + assert "invalid answers: Forbidden Hot Dogs, Illegal Cookies" in errors_on(changeset).ranked_answers + end + + test "failure: a vote should not include duplicate answer values", + %{ + published_ballot: published_ballot + } do + attrs = %{ + "ranked_answers" => [ + %{"value" => "Pizza"}, + %{"value" => "Tacos"}, + %{"value" => "Pizza"} + ] + } + + assert {:error, changeset} = RankedVoting.record_vote(published_ballot, attrs) + %Ecto.Changeset{changes: %{ranked_answers: ranked_answers_changesets}} = changeset + pizza_1 = Enum.at(ranked_answers_changesets, 0) + tacos = Enum.at(ranked_answers_changesets, 1) + pizza_2 = Enum.at(ranked_answers_changesets, 2) + + assert "answers must not be duplicated" in errors_on(pizza_1).value + assert %{} == errors_on(tacos) + assert "answers must not be duplicated" in errors_on(pizza_2).value + end + + test "failure: a vote needs to include at least one ranked answer", ~M{published_ballot} do + attrs = %{ + "ranked_answers" => [ + %{"value" => ""}, + %{"value" => ""}, + %{"value" => ""}, + %{"value" => ""} + ] + } + + assert {:error, changeset} = RankedVoting.record_vote(published_ballot, attrs) + %Ecto.Changeset{changes: %{ranked_answers: ranked_answers_changesets}} = changeset + first_ranked_answer = Enum.at(ranked_answers_changesets, 0) + assert "first answer is required" in errors_on(first_ranked_answer).value + end + end + + describe "change_vote/2" do + # TODO + end +end diff --git a/test/flick/votes_test.exs b/test/flick/votes_test.exs deleted file mode 100644 index ed8c18b..0000000 --- a/test/flick/votes_test.exs +++ /dev/null @@ -1,129 +0,0 @@ -defmodule Flick.VotesTest do - use Flick.DataCase, async: true - - alias Flick.Votes - alias Flick.Votes.Vote - alias Flick.Votes.RankedAnswer - alias Flick.Ballots - - describe "record_vote/2" do - setup do - ballot = - ballot_fixture( - question_title: "What's for dinner?", - possible_answers: "Pizza, Tacos, Sushi, Burgers" - ) - - {:ok, ballot} = Ballots.publish_ballot(ballot) - - {:ok, published_ballot: ballot} - end - - test "success: creates a vote recording the passed in answers", ~M{published_ballot} do - published_ballot_id = published_ballot.id - - assert {:ok, vote} = - Votes.record_vote(published_ballot, %{ - "ranked_answers" => [ - %{"value" => "Tacos"}, - %{"value" => "Pizza"}, - %{"value" => "Burgers"}, - %{"value" => "Sushi"} - ] - }) - - assert %Vote{ - ballot_id: ^published_ballot_id, - ranked_answers: [ - %RankedAnswer{value: "Tacos"}, - %RankedAnswer{value: "Pizza"}, - %RankedAnswer{value: "Burgers"}, - %RankedAnswer{value: "Sushi"} - ] - } = vote - end - - test "success: a vote does not need to rank every possible answer", ~M{published_ballot} do - published_ballot_id = published_ballot.id - - assert {:ok, vote} = - Votes.record_vote(published_ballot, %{ - "ranked_answers" => [ - %{"value" => "Sushi"}, - %{"value" => "Pizza"}, - %{"value" => ""}, - %{"value" => ""} - ] - }) - - assert %Vote{ - ballot_id: ^published_ballot_id, - ranked_answers: [ - %RankedAnswer{value: "Sushi"}, - %RankedAnswer{value: "Pizza"}, - %RankedAnswer{value: nil}, - %RankedAnswer{value: nil} - ] - } = vote - end - - test "failure: a vote should not include an answer value that is not present in the ballot", - %{ - published_ballot: published_ballot - } do - attrs = %{ - "ranked_answers" => [ - %{"value" => "Forbidden Hot Dogs"}, - %{"value" => "Illegal Cookies"} - ] - } - - assert {:error, changeset} = Votes.record_vote(published_ballot, attrs) - - assert "invalid answers: Forbidden Hot Dogs, Illegal Cookies" in errors_on(changeset).ranked_answers - end - - test "failure: a vote should not include duplicate answer values", - %{ - published_ballot: published_ballot - } do - attrs = %{ - "ranked_answers" => [ - %{"value" => "Pizza"}, - %{"value" => "Tacos"}, - %{"value" => "Pizza"} - ] - } - - assert {:error, changeset} = Votes.record_vote(published_ballot, attrs) - %Ecto.Changeset{changes: %{ranked_answers: ranked_answers_changesets}} = changeset - pizza_1 = Enum.at(ranked_answers_changesets, 0) - tacos = Enum.at(ranked_answers_changesets, 1) - pizza_2 = Enum.at(ranked_answers_changesets, 2) - - assert "answers must not be duplicated" in errors_on(pizza_1).value - assert %{} == errors_on(tacos) - assert "answers must not be duplicated" in errors_on(pizza_2).value - end - - test "failure: a vote needs to include at least one ranked answer", ~M{published_ballot} do - attrs = %{ - "ranked_answers" => [ - %{"value" => ""}, - %{"value" => ""}, - %{"value" => ""}, - %{"value" => ""} - ] - } - - assert {:error, changeset} = Votes.record_vote(published_ballot, attrs) - %Ecto.Changeset{changes: %{ranked_answers: ranked_answers_changesets}} = changeset - first_ranked_answer = Enum.at(ranked_answers_changesets, 0) - assert "first answer is required" in errors_on(first_ranked_answer).value - end - end - - describe "change_vote/2" do - # TODO - end -end diff --git a/test/support/fixtures/ballot_fixture.ex b/test/support/fixtures/ballot_fixture.ex index 4b7e196..27163f9 100644 --- a/test/support/fixtures/ballot_fixture.ex +++ b/test/support/fixtures/ballot_fixture.ex @@ -1,11 +1,11 @@ defmodule Support.Fixtures.BallotFixture do @moduledoc """ Provides functions to allows tests to easily create and stage - `Flick.Ballots.Ballot` entities for testing. + `Flick.RankedVoting.Ballot` entities for testing. """ @doc """ - Returns a map of valid attributes for a `Flick.Ballots.Ballot` entity, + Returns a map of valid attributes for a `Flick.RankedVoting.Ballot` entity, allowing for the passed in attributes to override defaults. """ def valid_ballot_attributes(attrs \\ %{}) do @@ -17,14 +17,14 @@ defmodule Support.Fixtures.BallotFixture do end @doc """ - Creates a `Flick.Ballots.Ballot` entity in the `Flick.Repo` for the passed in + Creates a `Flick.RankedVoting.Ballot` entity in the `Flick.Repo` for the passed in optional attributes. When not provided, all required attributes will be generated. """ def ballot_fixture(attrs \\ %{}) do attrs = valid_ballot_attributes(attrs) - {:ok, ballot} = Flick.Ballots.create_ballot(attrs) + {:ok, ballot} = Flick.RankedVoting.create_ballot(attrs) ballot end end