From aec0deef8b3018ddcced65c7bd883514922b7539 Mon Sep 17 00:00:00 2001 From: Yonle Date: Tue, 5 May 2026 13:49:29 +0700 Subject: [PATCH 1/3] poll_view: try to read votersCount first, and then manually count local voters. --- lib/pleroma/web/mastodon_api/views/poll_view.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/poll_view.ex b/lib/pleroma/web/mastodon_api/views/poll_view.ex index 3b4271227..67f874b46 100644 --- a/lib/pleroma/web/mastodon_api/views/poll_view.ex +++ b/lib/pleroma/web/mastodon_api/views/poll_view.ex @@ -71,12 +71,12 @@ defmodule Pleroma.Web.MastodonAPI.PollView do end) end + defp voters_count(%{data: %{"votersCount" => voters}}), do: voters + defp voters_count(%{data: %{"voters" => [_ | _] = voters}}) do length(voters) end - defp voters_count(%{data: %{"votersCount" => voters}}), do: voters - defp voters_count(_), do: 0 defp voted_and_own_votes(%{object: object} = params, options) do From 727e9e77497f20e2b61bd6267555015d2c308696 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Wed, 6 May 2026 11:33:34 +0400 Subject: [PATCH 2/3] Fix votersCount inflation in multiple-choice polls increase_vote_count/3 was incrementing votersCount on every vote activity, causing inflation when a single voter picks multiple options. Now only increments when the actor is a new unique voter, and preserves existing votersCount otherwise. Also adds is_integer guard to voters_count/1 to handle nil safely, and adds tests for the voters_count clause ordering and edge cases. --- lib/pleroma/object.ex | 18 +- .../web/mastodon_api/views/poll_view.ex | 2 +- .../web/mastodon_api/views/poll_view_test.exs | 175 ++++++++++++++++++ 3 files changed, 189 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index f1e07a257..5e9314446 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -372,13 +372,21 @@ defmodule Pleroma.Object do option end) - voters = [actor | object.data["voters"] || []] |> Enum.uniq() + existing_voters = object.data["voters"] || [] + voters = [actor | existing_voters] |> Enum.uniq() + new_voter? = actor not in existing_voters + existing_voters_count = object.data["votersCount"] voters_count = - if Map.has_key?(object.data, "votersCount") do - object.data["votersCount"] + 1 - else - length(voters) + cond do + is_integer(existing_voters_count) and new_voter? -> + existing_voters_count + 1 + + is_integer(existing_voters_count) -> + existing_voters_count + + true -> + length(voters) end data = diff --git a/lib/pleroma/web/mastodon_api/views/poll_view.ex b/lib/pleroma/web/mastodon_api/views/poll_view.ex index 67f874b46..f047804e2 100644 --- a/lib/pleroma/web/mastodon_api/views/poll_view.ex +++ b/lib/pleroma/web/mastodon_api/views/poll_view.ex @@ -71,7 +71,7 @@ defmodule Pleroma.Web.MastodonAPI.PollView do end) end - defp voters_count(%{data: %{"votersCount" => voters}}), do: voters + defp voters_count(%{data: %{"votersCount" => voters}}) when is_integer(voters), do: voters defp voters_count(%{data: %{"voters" => [_ | _] = voters}}) do length(voters) diff --git a/test/pleroma/web/mastodon_api/views/poll_view_test.exs b/test/pleroma/web/mastodon_api/views/poll_view_test.exs index 16281393d..6cb5934de 100644 --- a/test/pleroma/web/mastodon_api/views/poll_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/poll_view_test.exs @@ -180,4 +180,179 @@ defmodule Pleroma.Web.MastodonAPI.PollViewTest do assert result[:pleroma][:non_anonymous] == true end + + test "prefers votersCount over voters list when both are present" do + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + status: "Which flavor?", + poll: %{options: ["chocolate", "vanilla"], expires_in: 20} + }) + + object = Object.normalize(activity, fetch: false) + + voter = insert(:user) + {:ok, _, object} = CommonAPI.vote(object, voter, [0]) + + assert object.data["votersCount"] == 1 + assert length(object.data["voters"]) == 1 + + object = %{ + object + | data: Map.put(object.data, "votersCount", 42) + } + + result = PollView.render("show.json", %{object: object}) + + assert result[:voters_count] == 42 + end + + test "falls back to voters list when votersCount is absent" do + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + status: "Which flavor?", + poll: %{options: ["chocolate", "vanilla"], expires_in: 20} + }) + + object = Object.normalize(activity, fetch: false) + + voter = insert(:user) + {:ok, _, object} = CommonAPI.vote(object, voter, [0]) + + assert length(object.data["voters"]) == 1 + + data = Map.delete(object.data, "votersCount") + object = %{object | data: data} + + result = PollView.render("show.json", %{object: object}) + + assert result[:voters_count] == 1 + end + + test "returns 0 when both votersCount and voters are absent" do + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + status: "Which flavor?", + poll: %{options: ["chocolate", "vanilla"], expires_in: 20} + }) + + object = Object.normalize(activity, fetch: false) + + data = + object.data + |> Map.delete("votersCount") + |> Map.delete("voters") + + object = %{object | data: data} + + result = PollView.render("show.json", %{object: object}) + + assert result[:voters_count] == 0 + end + + test "returns 0 when voters list is empty" do + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + status: "Which flavor?", + poll: %{options: ["chocolate", "vanilla"], expires_in: 20} + }) + + object = Object.normalize(activity, fetch: false) + + data = + object.data + |> Map.delete("votersCount") + |> Map.put("voters", []) + + object = %{object | data: data} + + result = PollView.render("show.json", %{object: object}) + + assert result[:voters_count] == 0 + end + + test "does not inflate votersCount when same voter picks multiple options" do + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + status: "Pick several", + poll: %{options: ["a", "b", "c"], expires_in: 20, multiple: true} + }) + + object = Object.normalize(activity, fetch: false) + + voter = insert(:user) + {:ok, _, object} = CommonAPI.vote(object, voter, [0, 2]) + + assert object.data["votersCount"] == 1 + assert length(object.data["voters"]) == 1 + end + + test "preserves votersCount from remote source when existing voter picks another option" do + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + status: "Pick several", + poll: %{options: ["a", "b"], expires_in: 20, multiple: true} + }) + + object = Object.normalize(activity, fetch: false) + + voter = insert(:user) + {:ok, _, object} = CommonAPI.vote(object, voter, [0, 1]) + + object = %{object | data: Map.put(object.data, "votersCount", 14)} + + result = PollView.render("show.json", %{object: object}) + + assert result[:voters_count] == 14 + end + + test "returns 0 when votersCount is explicitly 0" do + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + status: "Pick one", + poll: %{options: ["a", "b"], expires_in: 20} + }) + + object = Object.normalize(activity, fetch: false) + + object = %{object | data: Map.put(object.data, "votersCount", 0)} + + result = PollView.render("show.json", %{object: object}) + + assert result[:voters_count] == 0 + end + + test "falls back to voters list when votersCount is nil" do + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + status: "Pick one", + poll: %{options: ["a", "b"], expires_in: 20} + }) + + object = Object.normalize(activity, fetch: false) + + voter = insert(:user) + {:ok, _, object} = CommonAPI.vote(object, voter, [0]) + + object = %{object | data: Map.put(object.data, "votersCount", nil)} + + result = PollView.render("show.json", %{object: object}) + + assert result[:voters_count] == length(object.data["voters"]) + end end From c62d1919867b287338f497d089acc81cd5b9a31b Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Wed, 6 May 2026 11:48:20 +0400 Subject: [PATCH 3/3] Add changelog for votersCount inflation fix --- changelog.d/poll-voters-count-inflation.fix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/poll-voters-count-inflation.fix diff --git a/changelog.d/poll-voters-count-inflation.fix b/changelog.d/poll-voters-count-inflation.fix new file mode 100644 index 000000000..7eae41f13 --- /dev/null +++ b/changelog.d/poll-voters-count-inflation.fix @@ -0,0 +1 @@ +Fix votersCount inflation when same voter picks multiple options