diff --git a/lib/flick/ranked_voting.ex b/lib/flick/ranked_voting.ex index cde9d19..40d45a2 100644 --- a/lib/flick/ranked_voting.ex +++ b/lib/flick/ranked_voting.ex @@ -4,14 +4,13 @@ defmodule Flick.RankedVoting do """ alias Flick.RankedVoting.Ballot + alias Flick.RankedVoting.Vote alias Flick.Repo - @typep changeset :: Ecto.Changeset.t(Ballot.t()) - @doc """ 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) @@ -25,7 +24,7 @@ defmodule Flick.RankedVoting do """ @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 @@ -46,7 +45,7 @@ defmodule Flick.RankedVoting do """ @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()) @@ -96,8 +95,41 @@ defmodule Flick.RankedVoting do @doc """ 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/ranked_voting/ranked_answer.ex b/lib/flick/ranked_voting/ranked_answer.ex index 0f7d283..5354c67 100644 --- a/lib/flick/ranked_voting/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/ranked_voting/vote.ex b/lib/flick/ranked_voting/vote.ex index 99b32d7..8afdc8e 100644 --- a/lib/flick/ranked_voting/vote.ex +++ b/lib/flick/ranked_voting/vote.ex @@ -10,7 +10,7 @@ defmodule Flick.RankedVoting.Vote do import FlickWeb.Gettext alias Flick.RankedVoting.Ballot - alias Flick.Votes.RankedAnswer + alias Flick.RankedVoting.RankedAnswer @type id :: Ecto.UUID.t() diff --git a/lib/flick/votes.ex b/lib/flick/votes.ex deleted file mode 100644 index 5a4db33..0000000 --- a/lib/flick/votes.ex +++ /dev/null @@ -1,45 +0,0 @@ -defmodule Flick.Votes do - @moduledoc """ - Provides functions related to capturing `Flick.RankedVoting.Vote` entities related to - a specific `Flick.RankedVoting.Ballot`. - """ - - alias Flick.RankedVoting.Ballot - alias Flick.Repo - alias Flick.RankedVoting.Vote - - @typep changeset :: Ecto.Changeset.t(Vote.t()) - - @doc """ - Records a vote for the given `Flick.RankedVoting.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.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()) :: 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/vote/vote_capture_live.ex b/lib/flick_web/live/vote/vote_capture_live.ex index 021c497..8c71572 100644 --- a/lib/flick_web/live/vote/vote_capture_live.ex +++ b/lib/flick_web/live/vote/vote_capture_live.ex @@ -8,7 +8,6 @@ defmodule FlickWeb.Vote.VoteCaptureLive do alias Flick.RankedVoting alias Flick.RankedVoting.Ballot - alias Flick.Votes alias Flick.RankedVoting.Vote alias Phoenix.LiveView.Socket @@ -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.") diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 45be358..41e382e 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -17,7 +17,7 @@ }) {:ok, _vote} = - Flick.Votes.record_vote(ballot, %{ + Flick.RankedVoting.record_vote(ballot, %{ "ranked_answers" => [ %{"value" => "Turkey"}, %{"value" => "Roast Beef"}, diff --git a/test/flick/ranked_voting_test.exs b/test/flick/ranked_voting_test.exs index 14f6116..6a3096a 100644 --- a/test/flick/ranked_voting_test.exs +++ b/test/flick/ranked_voting_test.exs @@ -7,6 +7,8 @@ defmodule Flick.RankedVotingTest do 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 @@ -157,7 +159,9 @@ defmodule Flick.RankedVotingTest do 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) + + 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 @@ -176,4 +180,125 @@ defmodule Flick.RankedVotingTest do } = 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 3c56908..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.RankedVoting.Vote - alias Flick.Votes.RankedAnswer - alias Flick.RankedVoting - - 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} = - 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