-
-
Notifications
You must be signed in to change notification settings - Fork 96
fix: handle invalid dealt_card_id in PlayerLive toggle_vote gracefully (fixes #2843) #2865
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 27 commits
40ac90f
af4125d
a91d1ad
d78a2b1
9bce348
efcf26e
45a27e3
8322fbf
c10edaf
50f34be
abe197d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -121,34 +121,39 @@ defmodule CopiWeb.PlayerLive.Show do | |
| game = socket.assigns.game | ||
| player = socket.assigns.player | ||
|
|
||
| {:ok, dealt_card} = DealtCard.find(dealt_card_id) | ||
|
|
||
| game_card_ids = game.players | ||
| |> Enum.flat_map(fn p -> p.dealt_cards end) | ||
| |> Enum.map(fn dc -> dc.id end) | ||
|
|
||
| if dealt_card.id in game_card_ids do | ||
| vote = get_vote(dealt_card, player) | ||
|
|
||
| if vote do | ||
| Logger.debug("Player has voted: player_id: #{player.id}, dealt_card_id: #{dealt_card_id}, game_id: #{game.id}") | ||
| Copi.Repo.delete!(vote) | ||
| else | ||
| Logger.debug("Player has not voted: player_id: #{player.id}, dealt_card_id: #{dealt_card_id}, game_id: #{game.id}") | ||
| case Copi.Repo.insert(%Copi.Cornucopia.Vote{dealt_card_id: String.to_integer(dealt_card_id), player_id: player.id}) do | ||
| {:ok, _vote} -> | ||
| Logger.debug("Vote added successfully for player_id: #{player.id}, dealt_card_id: #{dealt_card_id}, game_id: #{game.id}") | ||
| {:error, changeset} -> | ||
| Logger.warning("Voting failed for player_id: #{player.id}, dealt_card_id: #{dealt_card_id}, game_id: #{game.id}, errors: #{inspect(changeset.errors)}") | ||
| case DealtCard.find(dealt_card_id) do | ||
| {:error, _reason} -> | ||
| Logger.warning("toggle_vote: dealt_card_id=#{dealt_card_id} not found for player_id=#{player.id}, game_id=#{game.id}") | ||
| {:noreply, socket |> put_flash(:error, "Invalid card selection")} | ||
|
Comment on lines
+124
to
+127
|
||
|
|
||
| {:ok, dealt_card} -> | ||
| game_card_ids = game.players | ||
| |> Enum.flat_map(fn p -> p.dealt_cards end) | ||
| |> Enum.map(fn dc -> dc.id end) | ||
|
|
||
| if dealt_card.id in game_card_ids do | ||
| vote = get_vote(dealt_card, player) | ||
|
|
||
| if vote do | ||
| Logger.debug("Player has voted: player_id: #{player.id}, dealt_card_id: #{dealt_card_id}, game_id: #{game.id}") | ||
| Copi.Repo.delete!(vote) | ||
| else | ||
| Logger.debug("Player has not voted: player_id: #{player.id}, dealt_card_id: #{dealt_card_id}, game_id: #{game.id}") | ||
| case Copi.Repo.insert(%Copi.Cornucopia.Vote{dealt_card_id: String.to_integer(dealt_card_id), player_id: player.id}) do | ||
|
immortal71 marked this conversation as resolved.
Outdated
|
||
| {:ok, _vote} -> | ||
| Logger.debug("Vote added successfully for player_id: #{player.id}, dealt_card_id: #{dealt_card_id}, game_id: #{game.id}") | ||
| {:error, changeset} -> | ||
| Logger.warning("Voting failed for player_id: #{player.id}, dealt_card_id: #{dealt_card_id}, game_id: #{game.id}, errors: #{inspect(changeset.errors)}") | ||
| end | ||
| end | ||
|
|
||
| {:ok, updated_game} = Game.find(game.id) | ||
| CopiWeb.Endpoint.broadcast(topic(updated_game.id), "game:updated", updated_game) | ||
| {:noreply, assign(socket, :game, updated_game)} | ||
| else | ||
| Logger.warning("Unauthorized vote attempt: player_id: #{player.id}, dealt_card_id: #{dealt_card_id}, game_id: #{game.id}") | ||
| {:noreply, socket |> put_flash(:error, "Invalid card selection")} | ||
| end | ||
| end | ||
|
|
||
| {:ok, updated_game} = Game.find(game.id) | ||
| CopiWeb.Endpoint.broadcast(topic(updated_game.id), "game:updated", updated_game) | ||
| {:noreply, assign(socket, :game, updated_game)} | ||
| else | ||
| Logger.warning("Unauthorized vote attempt: player_id: #{player.id}, dealt_card_id: #{dealt_card_id}, game_id: #{game.id}") | ||
| {:noreply, socket |> put_flash(:error, "Invalid card selection")} | ||
| end | ||
| end | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| defmodule Copi.Cornucopia.DealtCardTest do | ||
| use Copi.DataCase, async: true | ||
|
|
||
| alias Copi.Cornucopia.DealtCard | ||
|
|
||
| describe "changeset/2" do | ||
| test "returns a valid changeset with empty attrs" do | ||
| changeset = DealtCard.changeset(%DealtCard{}, %{}) | ||
| assert changeset.valid? | ||
| end | ||
|
|
||
| test "casts played_in_round when provided" do | ||
| changeset = DealtCard.changeset(%DealtCard{}, %{played_in_round: 3}) | ||
| # changeset/2 only casts [], so played_in_round is ignored but changeset is valid | ||
|
immortal71 marked this conversation as resolved.
Outdated
|
||
| assert changeset.valid? | ||
| end | ||
| end | ||
|
|
||
| describe "find/1" do | ||
| test "returns error for nonexistent dealt card id" do | ||
| assert {:error, :not_found} = DealtCard.find(-1) | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| defmodule Copi.Cornucopia.PlayerTest do | ||
| use ExUnit.Case, async: true | ||
|
|
||
| alias Copi.Cornucopia.Player | ||
|
|
||
| describe "changeset/2" do | ||
| test "valid with name only" do | ||
| # game_id is not in validate_required, so name alone is enough | ||
| changeset = Player.changeset(%Player{}, %{name: "Alice"}) | ||
| assert changeset.valid? | ||
| end | ||
|
|
||
| test "invalid without name" do | ||
| changeset = Player.changeset(%Player{}, %{}) | ||
| refute changeset.valid? | ||
| assert %{name: ["can't be blank"]} = errors_on(changeset) | ||
| end | ||
|
|
||
| test "invalid when name too long" do | ||
| changeset = Player.changeset(%Player{}, %{name: String.duplicate("a", 51)}) | ||
| refute changeset.valid? | ||
| end | ||
| end | ||
|
|
||
| defp errors_on(changeset) do | ||
| Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> | ||
| Enum.reduce(opts, msg, fn {key, value}, acc -> | ||
| String.replace(acc, "%{#{key}}", to_string(value)) | ||
| end) | ||
| end) | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| defmodule Copi.Cornucopia.VoteTest do | ||
| use ExUnit.Case, async: true | ||
|
|
||
| alias Copi.Cornucopia.Vote | ||
|
|
||
| describe "changeset/2" do | ||
| test "valid with empty attrs" do | ||
| changeset = Vote.changeset(%Vote{}, %{}) | ||
| assert changeset.valid? | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| defmodule CopiWeb.GameLive.ShowPureTest do | ||
| use ExUnit.Case, async: true | ||
|
|
||
| alias CopiWeb.GameLive.Show | ||
|
|
||
| test "display_game_session returns expected labels" do | ||
| assert Show.display_game_session("webapp") == "Cornucopia Web Session:" | ||
| assert Show.display_game_session("mobileapp") == "Cornucopia Mobile Session:" | ||
| assert Show.display_game_session("mlsec") == "Elevation of MLSec Session:" | ||
| assert Show.display_game_session("unknown") == "EoP Session:" | ||
| end | ||
|
|
||
| test "latest_version returns expected versions" do | ||
| assert Show.latest_version("webapp") == "2.2" | ||
| assert Show.latest_version("ecommerce") == "1.22" | ||
| assert Show.latest_version("eop") == "5.1" | ||
| assert Show.latest_version("other") == "1.0" | ||
| end | ||
|
|
||
| test "card_played_in_round finds card or returns nil" do | ||
| cards = [%{played_in_round: 1, id: "a"}, %{played_in_round: 3, id: "b"}] | ||
| assert Show.card_played_in_round(cards, 3) == %{played_in_round: 3, id: "b"} | ||
| assert Show.card_played_in_round(cards, 2) == nil | ||
| end | ||
|
|
||
| test "topic builds topic string" do | ||
| assert Show.topic(42) == "game:42" | ||
| assert Show.topic("abc") == "game:abc" | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| defmodule CopiWeb.PlayerLive.ShowPureTest do | ||
| use ExUnit.Case, async: true | ||
|
|
||
| alias CopiWeb.PlayerLive.Show | ||
| alias CopiWeb.PlayerLive.FormComponent | ||
|
|
||
| test "topic/1 builds topic strings" do | ||
| assert Show.topic(1) == "game:1" | ||
| assert Show.topic("abc") == "game:abc" | ||
| end | ||
|
|
||
| test "FormComponent.topic/1 builds topic strings" do | ||
| assert FormComponent.topic(99) == "game:99" | ||
| assert FormComponent.topic("xyz") == "game:xyz" | ||
| end | ||
|
|
||
| test "ordered_cards/1 sorts by card.id" do | ||
| cards = [%{card: %{id: 3}}, %{card: %{id: 1}}, %{card: %{id: 2}}] | ||
| sorted = Show.ordered_cards(cards) | ||
| assert Enum.map(sorted, & &1.card.id) == [1, 2, 3] | ||
| end | ||
|
|
||
| test "unplayed_cards/1 filters unplayed (0 or nil)" do | ||
| cards = [%{played_in_round: 0, card: %{id: 1}}, %{played_in_round: nil, card: %{id: 2}}, %{played_in_round: 2, card: %{id: 3}}] | ||
| res = Show.unplayed_cards(cards) | ||
| assert Enum.map(res, & &1.card.id) == [1, 2] | ||
| end | ||
|
|
||
| test "played_cards/2 returns those in given round" do | ||
| cards = [%{played_in_round: 1, card: %{id: 1}}, %{played_in_round: 2, card: %{id: 2}}] | ||
| assert Show.played_cards(cards, 2) == [%{played_in_round: 2, card: %{id: 2}}] | ||
| end | ||
|
|
||
| test "card_played_in_round/2 finds first matching card" do | ||
| cards = [%{played_in_round: 1, card: %{id: 1}}, %{played_in_round: 2, card: %{id: 2}}] | ||
| assert Show.card_played_in_round(cards, 2) == %{played_in_round: 2, card: %{id: 2}} | ||
| assert Show.card_played_in_round([], 1) == nil | ||
| end | ||
|
|
||
| test "player_first/2 places given player first" do | ||
| players = [%{id: 2}, %{id: 1}, %{id: 3}] | ||
| res = Show.player_first(players, %{id: 1}) | ||
| assert hd(res).id == 1 | ||
| end | ||
|
|
||
| test "round_open?/1 and round_closed?/1 reflect player dealt cards" do | ||
| # latest_round = rounds_played + 1 = 2 | ||
| # player1 has a card for round 2 -> has played | ||
| player1 = %{id: 1, dealt_cards: [%{played_in_round: 2}]} | ||
| # player2 has no card for round 2 -> still to play | ||
| player2 = %{id: 2, dealt_cards: [%{played_in_round: 1}]} | ||
| game = %{rounds_played: 1, players: [player1, player2]} | ||
|
|
||
| assert Show.round_open?(game) == true | ||
| assert Show.round_closed?(game) == false | ||
| end | ||
|
|
||
| test "last_round?/1 detects when players have no nil played_in_round" do | ||
| # player with no nil played_in_round => they have no cards left | ||
| player1 = %{dealt_cards: [%{played_in_round: 1}]} # no nil cards | ||
| player2 = %{dealt_cards: [%{played_in_round: nil}]} # has nil -> still has cards | ||
| game = %{players: [player1, player2]} | ||
|
|
||
| # last_round? returns true when there is at least one player with no nil cards | ||
| assert Show.last_round?(game) == true | ||
| end | ||
|
|
||
| test "get_vote/2 finds vote by player id" do | ||
| votes = [%{player_id: 1, id: 10}, %{player_id: 2, id: 11}] | ||
| dealt_card = %{votes: votes} | ||
| player = %{id: 2} | ||
| assert Show.get_vote(dealt_card, player) == %{player_id: 2, id: 11} | ||
| end | ||
|
|
||
| test "display_game_session/1 returns correct label for every edition" do | ||
| assert Show.display_game_session("webapp") == "Cornucopia Web Session:" | ||
| assert Show.display_game_session("ecommerce") == "Cornucopia Web Session:" | ||
| assert Show.display_game_session("mobileapp") == "Cornucopia Mobile Session:" | ||
| assert Show.display_game_session("masvs") == "Cornucopia Mobile Session:" | ||
| assert Show.display_game_session("cumulus") == "OWASP Cumulus Session:" | ||
| assert Show.display_game_session("mlsec") == "Elevation of MLSec Session:" | ||
| assert Show.display_game_session("eop") == "EoP Session:" | ||
| assert Show.display_game_session("unknown") == "EoP Session:" | ||
| end | ||
| end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This template change (guarding the form component when
@playeris nil on the index route) isn’t mentioned in the PR description, which focuses ontoggle_vote. Please update the PR description to include this additional behavior change, or split it into a separate PR if it’s unrelated to #2843.