Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85825990
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Flag For Later
Award Token
Authored By
Unknown
Size
68 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/changelog.d/poll-refresh.change b/changelog.d/poll-refresh.change
new file mode 100644
index 000000000..b755128a1
--- /dev/null
+++ b/changelog.d/poll-refresh.change
@@ -0,0 +1 @@
+Poll results refreshing is handled asynchronously and will not attempt to keep fetching updates to a closed poll.
diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex
index 748f18e6c..77dfda851 100644
--- a/lib/pleroma/object.ex
+++ b/lib/pleroma/object.ex
@@ -1,507 +1,486 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Object do
use Ecto.Schema
import Ecto.Query
import Ecto.Changeset
alias Pleroma.Activity
alias Pleroma.Config
alias Pleroma.Hashtag
alias Pleroma.Object
alias Pleroma.Object.Fetcher
alias Pleroma.ObjectTombstone
alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.Workers.AttachmentsCleanupWorker
require Logger
@type t() :: %__MODULE__{}
@derive {Jason.Encoder, only: [:data]}
@cachex Pleroma.Config.get([:cachex, :provider], Cachex)
schema "objects" do
field(:data, :map)
many_to_many(:hashtags, Hashtag, join_through: "hashtags_objects", on_replace: :delete)
timestamps()
end
def with_joined_activity(query, activity_type \\ "Create", join_type \\ :inner) do
object_position = Map.get(query.aliases, :object, 0)
join(query, join_type, [{object, object_position}], a in Activity,
on:
fragment(
"associated_object_id(?) = (? ->> 'id') AND (?->>'type' = ?) ",
a.data,
object.data,
a.data,
^activity_type
),
as: :object_activity
)
end
def create(data) do
%Object{}
|> Object.change(%{data: data})
|> Repo.insert()
end
def change(struct, params \\ %{}) do
struct
|> cast(params, [:data])
|> validate_required([:data])
|> unique_constraint(:ap_id, name: :objects_unique_apid_index)
# Expecting `maybe_handle_hashtags_change/1` to run last:
|> maybe_handle_hashtags_change(struct)
end
# Note: not checking activity type (assuming non-legacy objects are associated with Create act.)
defp maybe_handle_hashtags_change(changeset, struct) do
with %Ecto.Changeset{valid?: true} <- changeset,
data_hashtags_change = get_change(changeset, :data),
{_, true} <- {:changed, hashtags_changed?(struct, data_hashtags_change)},
{:ok, hashtag_records} <-
data_hashtags_change
|> object_data_hashtags()
|> Hashtag.get_or_create_by_names() do
put_assoc(changeset, :hashtags, hashtag_records)
else
%{valid?: false} ->
changeset
{:changed, false} ->
changeset
{:error, _} ->
validate_change(changeset, :data, fn _, _ ->
[data: "error referencing hashtags"]
end)
end
end
defp hashtags_changed?(%Object{} = struct, %{"tag" => _} = data) do
Enum.sort(embedded_hashtags(struct)) !=
Enum.sort(object_data_hashtags(data))
end
defp hashtags_changed?(_, _), do: false
def get_by_id(nil), do: nil
def get_by_id(id), do: Repo.get(Object, id)
- @spec get_by_id_and_maybe_refetch(integer(), list()) :: Object.t() | nil
- def get_by_id_and_maybe_refetch(id, opts \\ []) do
- with %Object{updated_at: updated_at} = object <- get_by_id(id) do
- if opts[:interval] &&
- NaiveDateTime.diff(NaiveDateTime.utc_now(), updated_at) > opts[:interval] do
- case Fetcher.refetch_object(object) do
- {:ok, %Object{} = object} ->
- object
-
- e ->
- Logger.error("Couldn't refresh #{object.data["id"]}:\n#{inspect(e)}")
- object
- end
- else
- object
- end
- else
- nil -> nil
- end
- end
-
def get_by_ap_id(nil), do: nil
def get_by_ap_id(ap_id) do
Repo.one(from(object in Object, where: fragment("(?)->>'id' = ?", object.data, ^ap_id)))
end
@doc """
Get a single attachment by it's name and href
"""
@spec get_attachment_by_name_and_href(String.t(), String.t()) :: Object.t() | nil
def get_attachment_by_name_and_href(name, href) do
query =
from(o in Object,
where: fragment("(?)->>'name' = ?", o.data, ^name),
where: fragment("(?)->>'href' = ?", o.data, ^href)
)
Repo.one(query)
end
defp warn_on_no_object_preloaded(ap_id) do
"Object.normalize() called without preloaded object (#{inspect(ap_id)}). Consider preloading the object"
|> Logger.debug()
Logger.debug("Backtrace: #{inspect(Process.info(:erlang.self(), :current_stacktrace))}")
end
def normalize(_, options \\ [fetch: false, id_only: false])
# If we pass an Activity to Object.normalize(), we can try to use the preloaded object.
# Use this whenever possible, especially when walking graphs in an O(N) loop!
def normalize(%Object{} = object, _), do: object
def normalize(%Activity{object: %Object{} = object}, _), do: object
# A hack for fake activities
def normalize(%Activity{data: %{"object" => %{"fake" => true} = data}}, _) do
%Object{id: "pleroma:fake_object_id", data: data}
end
# No preloaded object
def normalize(%Activity{data: %{"object" => %{"id" => ap_id}}}, options) do
warn_on_no_object_preloaded(ap_id)
normalize(ap_id, options)
end
# No preloaded object
def normalize(%Activity{data: %{"object" => ap_id}}, options) do
warn_on_no_object_preloaded(ap_id)
normalize(ap_id, options)
end
# Old way, try fetching the object through cache.
def normalize(%{"id" => ap_id}, options), do: normalize(ap_id, options)
def normalize(ap_id, options) when is_binary(ap_id) do
cond do
Keyword.get(options, :id_only) ->
ap_id
Keyword.get(options, :fetch) ->
case Fetcher.fetch_object_from_id(ap_id, options) do
{:ok, object} -> object
_ -> nil
end
true ->
get_cached_by_ap_id(ap_id)
end
end
def normalize(_, _), do: nil
# Owned objects can only be accessed by their owner
def authorize_access(%Object{data: %{"actor" => actor}}, %User{ap_id: ap_id}) do
if actor == ap_id do
:ok
else
{:error, :forbidden}
end
end
# Legacy objects can be accessed by anybody
def authorize_access(%Object{}, %User{}), do: :ok
@spec get_cached_by_ap_id(String.t()) :: Object.t() | nil
def get_cached_by_ap_id(ap_id) do
key = "object:#{ap_id}"
with {:ok, nil} <- @cachex.get(:object_cache, key),
object when not is_nil(object) <- get_by_ap_id(ap_id),
{:ok, true} <- @cachex.put(:object_cache, key, object) do
object
else
{:ok, object} -> object
nil -> nil
end
end
def make_tombstone(%Object{data: %{"id" => id, "type" => type}}, deleted \\ DateTime.utc_now()) do
%ObjectTombstone{
id: id,
formerType: type,
deleted: deleted
}
|> Map.from_struct()
end
def swap_object_with_tombstone(object) do
tombstone = make_tombstone(object)
with {:ok, object} <-
object
|> Object.change(%{data: tombstone})
|> Repo.update() do
Hashtag.unlink(object)
{:ok, object}
end
end
def delete(%Object{data: %{"id" => id}} = object) do
with {:ok, _obj} = swap_object_with_tombstone(object),
deleted_activity = Activity.delete_all_by_object_ap_id(id),
{:ok, _} <- invalid_object_cache(object) do
cleanup_attachments(
Config.get([:instance, :cleanup_attachments]),
object
)
{:ok, object, deleted_activity}
end
end
@spec cleanup_attachments(boolean(), Object.t()) ::
{:ok, Oban.Job.t() | nil}
def cleanup_attachments(true, %Object{} = object) do
AttachmentsCleanupWorker.new(%{"op" => "cleanup_attachments", "object" => object})
|> Oban.insert()
end
def cleanup_attachments(_, _), do: {:ok, nil}
def prune(%Object{data: %{"id" => _id}} = object) do
with {:ok, object} <- Repo.delete(object),
{:ok, _} <- invalid_object_cache(object) do
{:ok, object}
end
end
def invalid_object_cache(%Object{data: %{"id" => id}}) do
with {:ok, true} <- @cachex.del(:object_cache, "object:#{id}") do
@cachex.del(:web_resp_cache, URI.parse(id).path)
end
end
def set_cache(%Object{data: %{"id" => ap_id}} = object) do
@cachex.put(:object_cache, "object:#{ap_id}", object)
{:ok, object}
end
def update_and_set_cache(changeset) do
with {:ok, object} <- Repo.update(changeset) do
set_cache(object)
end
end
def increase_replies_count(ap_id) do
Object
|> where([o], fragment("?->>'id' = ?::text", o.data, ^to_string(ap_id)))
|> update([o],
set: [
data:
fragment(
"""
safe_jsonb_set(?, '{repliesCount}',
(coalesce((?->>'repliesCount')::int, 0) + 1)::varchar::jsonb, true)
""",
o.data,
o.data
)
]
)
|> Repo.update_all([])
|> case do
{1, [object]} -> set_cache(object)
_ -> {:error, "Not found"}
end
end
defp poll_is_multiple?(%Object{data: %{"anyOf" => [_ | _]}}), do: true
defp poll_is_multiple?(_), do: false
def decrease_replies_count(ap_id) do
Object
|> where([o], fragment("?->>'id' = ?::text", o.data, ^to_string(ap_id)))
|> update([o],
set: [
data:
fragment(
"""
safe_jsonb_set(?, '{repliesCount}',
(greatest(0, (?->>'repliesCount')::int - 1))::varchar::jsonb, true)
""",
o.data,
o.data
)
]
)
|> Repo.update_all([])
|> case do
{1, [object]} -> set_cache(object)
_ -> {:error, "Not found"}
end
end
def increase_quotes_count(ap_id) do
Object
|> where([o], fragment("?->>'id' = ?::text", o.data, ^to_string(ap_id)))
|> update([o],
set: [
data:
fragment(
"""
safe_jsonb_set(?, '{quotesCount}',
(coalesce((?->>'quotesCount')::int, 0) + 1)::varchar::jsonb, true)
""",
o.data,
o.data
)
]
)
|> Repo.update_all([])
|> case do
{1, [object]} -> set_cache(object)
_ -> {:error, "Not found"}
end
end
def decrease_quotes_count(ap_id) do
Object
|> where([o], fragment("?->>'id' = ?::text", o.data, ^to_string(ap_id)))
|> update([o],
set: [
data:
fragment(
"""
safe_jsonb_set(?, '{quotesCount}',
(greatest(0, (?->>'quotesCount')::int - 1))::varchar::jsonb, true)
""",
o.data,
o.data
)
]
)
|> Repo.update_all([])
|> case do
{1, [object]} -> set_cache(object)
_ -> {:error, "Not found"}
end
end
def increase_vote_count(ap_id, name, actor) do
with %Object{} = object <- Object.normalize(ap_id, fetch: false),
"Question" <- object.data["type"] do
key = if poll_is_multiple?(object), do: "anyOf", else: "oneOf"
options =
object.data[key]
|> Enum.map(fn
%{"name" => ^name} = option ->
Kernel.update_in(option["replies"]["totalItems"], &(&1 + 1))
option ->
option
end)
voters = [actor | object.data["voters"] || []] |> Enum.uniq()
data =
object.data
|> Map.put(key, options)
|> Map.put("voters", voters)
object
|> Object.change(%{data: data})
|> update_and_set_cache()
else
_ -> :noop
end
end
@doc "Updates data field of an object"
def update_data(%Object{data: data} = object, attrs \\ %{}) do
object
|> Object.change(%{data: Map.merge(data || %{}, attrs)})
|> Repo.update()
end
def local?(%Object{data: %{"id" => id}}) do
String.starts_with?(id, Pleroma.Web.Endpoint.url() <> "/")
end
def replies(object, opts \\ []) do
object = Object.normalize(object, fetch: false)
query =
Object
|> where(
[o],
fragment("(?)->>'inReplyTo' = ?", o.data, ^object.data["id"])
)
|> order_by([o], asc: o.id)
if opts[:self_only] do
actor = object.data["actor"]
where(query, [o], fragment("(?)->>'actor' = ?", o.data, ^actor))
else
query
end
end
def self_replies(object, opts \\ []),
do: replies(object, Keyword.put(opts, :self_only, true))
def tags(%Object{data: %{"tag" => tags}}) when is_list(tags), do: tags
def tags(_), do: []
def hashtags(%Object{} = object) do
# Note: always using embedded hashtags regardless whether they are migrated to hashtags table
# (embedded hashtags stay in sync anyways, and we avoid extra joins and preload hassle)
embedded_hashtags(object)
end
def embedded_hashtags(%Object{data: data}) do
object_data_hashtags(data)
end
def embedded_hashtags(_), do: []
def object_data_hashtags(%{"tag" => tags}) when is_list(tags) do
tags
|> Enum.filter(fn
%{"type" => "Hashtag"} = data -> Map.has_key?(data, "name")
plain_text when is_bitstring(plain_text) -> true
_ -> false
end)
|> Enum.map(fn
%{"name" => "#" <> hashtag} -> String.downcase(hashtag)
%{"name" => hashtag} -> String.downcase(hashtag)
hashtag when is_bitstring(hashtag) -> String.downcase(hashtag)
end)
|> Enum.uniq()
# Note: "" elements (plain text) might occur in `data.tag` for incoming objects
|> Enum.filter(&(&1 not in [nil, ""]))
end
def object_data_hashtags(_), do: []
def get_emoji_reactions(object) do
reactions = object.data["reactions"]
if is_list(reactions) or is_map(reactions) do
reactions
|> Enum.map(fn
[_emoji, users, _maybe_url] = item when is_list(users) ->
item
[emoji, users] when is_list(users) ->
[emoji, users, nil]
# This case is here to process the Map situation, which will happen
# only with the legacy two-value format.
{emoji, users} when is_list(users) ->
[emoji, users, nil]
_ ->
nil
end)
|> Enum.reject(&is_nil/1)
else
[]
end
end
end
diff --git a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex
index a2af8148c..6526457df 100644
--- a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex
@@ -1,73 +1,87 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.PollController do
use Pleroma.Web, :controller
import Pleroma.Web.ControllerHelper, only: [try_render: 3, json_response: 3]
alias Pleroma.Activity
alias Pleroma.Object
alias Pleroma.Web.ActivityPub.Visibility
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.Plugs.OAuthScopesPlug
+ alias Pleroma.Workers.PollWorker
action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false)
plug(
OAuthScopesPlug,
%{scopes: ["read:statuses"], fallback: :proceed_unauthenticated} when action == :show
)
plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action == :vote)
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PollOperation
@cachex Pleroma.Config.get([:cachex, :provider], Cachex)
+ @poll_refresh_interval 120
@doc "GET /api/v1/polls/:id"
def show(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do
- with %Object{} = object <- Object.get_by_id_and_maybe_refetch(id, interval: 60),
- %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]),
+ with %Object{} = object <- Object.get_by_id(id),
+ %Activity{} = activity <-
+ Activity.get_create_by_object_ap_id_with_object(object.data["id"]),
true <- Visibility.visible_for_user?(activity, user) do
+ maybe_refresh_poll(activity)
+
try_render(conn, "show.json", %{object: object, for: user})
else
error when is_nil(error) or error == false ->
render_error(conn, :not_found, "Record not found")
end
end
@doc "POST /api/v1/polls/:id/votes"
def vote(
%{
assigns: %{user: user},
private: %{open_api_spex: %{body_params: %{choices: choices}, params: %{id: id}}}
} = conn,
_
) do
with %Object{data: %{"type" => "Question"}} = object <- Object.get_by_id(id),
%Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]),
true <- Visibility.visible_for_user?(activity, user),
{:ok, _activities, object} <- get_cached_vote_or_vote(object, user, choices) do
try_render(conn, "show.json", %{object: object, for: user})
else
nil -> render_error(conn, :not_found, "Record not found")
false -> render_error(conn, :not_found, "Record not found")
{:error, message} -> json_response(conn, :unprocessable_entity, %{error: message})
end
end
defp get_cached_vote_or_vote(object, user, choices) do
idempotency_key = "polls:#{user.id}:#{object.data["id"]}"
@cachex.fetch!(:idempotency_cache, idempotency_key, fn _ ->
case CommonAPI.vote(object, user, choices) do
{:error, _message} = res -> {:ignore, res}
res -> {:commit, res}
end
end)
end
+
+ defp maybe_refresh_poll(%Activity{object: %Object{} = object} = activity) do
+ with false <- activity.local,
+ {:ok, end_time} <- NaiveDateTime.from_iso8601(object.data["closed"]),
+ {_, :lt} <- {:closed_compare, NaiveDateTime.compare(object.updated_at, end_time)} do
+ PollWorker.new(%{"op" => "refresh", "activity_id" => activity.id})
+ |> Oban.insert(unique: [period: @poll_refresh_interval])
+ end
+ end
end
diff --git a/lib/pleroma/workers/poll_worker.ex b/lib/pleroma/workers/poll_worker.ex
index d263aa1b9..a9afe9d63 100644
--- a/lib/pleroma/workers/poll_worker.ex
+++ b/lib/pleroma/workers/poll_worker.ex
@@ -1,52 +1,77 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Workers.PollWorker do
@moduledoc """
Generates notifications when a poll ends.
"""
use Oban.Worker, queue: :background
alias Pleroma.Activity
alias Pleroma.Notification
alias Pleroma.Object
+ alias Pleroma.Object.Fetcher
+
+ @stream_out_impl Pleroma.Config.get(
+ [__MODULE__, :stream_out],
+ Pleroma.Web.ActivityPub.ActivityPub
+ )
@impl true
def perform(%Job{args: %{"op" => "poll_end", "activity_id" => activity_id}}) do
- with %Activity{} = activity <- find_poll_activity(activity_id),
+ with {_, %Activity{} = activity} <- {:activity, Activity.get_by_id(activity_id)},
{:ok, notifications} <- Notification.create_poll_notifications(activity) do
+ unless activity.local do
+ # Schedule a final refresh
+ __MODULE__.new(%{"op" => "refresh", "activity_id" => activity_id})
+ |> Oban.insert()
+ end
+
Notification.stream(notifications)
else
- {:error, :poll_activity_not_found} = e -> {:cancel, e}
+ {:activity, nil} -> {:cancel, :poll_activity_not_found}
e -> {:error, e}
end
end
- @impl true
- def timeout(_job), do: :timer.seconds(5)
+ def perform(%Job{args: %{"op" => "refresh", "activity_id" => activity_id}}) do
+ with {_, %Activity{object: object}} <-
+ {:activity, Activity.get_by_id_with_object(activity_id)},
+ {_, {:ok, _object}} <- {:refetch, Fetcher.refetch_object(object)} do
+ stream_update(activity_id)
- defp find_poll_activity(activity_id) do
- with nil <- Activity.get_by_id(activity_id) do
- {:error, :poll_activity_not_found}
+ :ok
+ else
+ {:activity, nil} -> {:cancel, :poll_activity_not_found}
+ {:refetch, _} = e -> {:cancel, e}
end
end
+ @impl true
+ def timeout(_job), do: :timer.seconds(5)
+
def schedule_poll_end(%Activity{data: %{"type" => "Create"}, id: activity_id} = activity) do
with %Object{data: %{"type" => "Question", "closed" => closed}} when is_binary(closed) <-
Object.normalize(activity),
{:ok, end_time} <- NaiveDateTime.from_iso8601(closed),
:gt <- NaiveDateTime.compare(end_time, NaiveDateTime.utc_now()) do
%{
op: "poll_end",
activity_id: activity_id
}
|> new(scheduled_at: end_time)
|> Oban.insert()
else
_ -> {:error, activity}
end
end
def schedule_poll_end(activity), do: {:error, activity}
+
+ defp stream_update(activity_id) do
+ Activity.get_by_id(activity_id)
+ |> Activity.normalize()
+ |> @stream_out_impl.stream_out()
+ end
end
diff --git a/test/pleroma/object_test.exs b/test/pleroma/object_test.exs
index 48d4d86eb..b3c528e32 100644
--- a/test/pleroma/object_test.exs
+++ b/test/pleroma/object_test.exs
@@ -1,488 +1,344 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.ObjectTest do
use Pleroma.DataCase
use Oban.Testing, repo: Pleroma.Repo
- import ExUnit.CaptureLog
import Mox
import Pleroma.Factory
import Tesla.Mock
- alias Pleroma.Activity
alias Pleroma.Hashtag
alias Pleroma.Object
alias Pleroma.Repo
alias Pleroma.Tests.ObanHelpers
alias Pleroma.UnstubbedConfigMock, as: ConfigMock
alias Pleroma.Web.CommonAPI
setup do
mock(fn env -> apply(HttpRequestMock, :request, [env]) end)
ConfigMock |> stub_with(Pleroma.Test.StaticConfig)
:ok
end
test "returns an object by it's AP id" do
object = insert(:note)
found_object = Object.get_by_ap_id(object.data["id"])
assert object == found_object
end
describe "generic changeset" do
test "it ensures uniqueness of the id" do
object = insert(:note)
cs = Object.change(%Object{}, %{data: %{id: object.data["id"]}})
assert cs.valid?
{:error, _result} = Repo.insert(cs)
end
end
describe "deletion function" do
test "deletes an object" do
object = insert(:note)
found_object = Object.get_by_ap_id(object.data["id"])
assert object == found_object
Object.delete(found_object)
found_object = Object.get_by_ap_id(object.data["id"])
refute object == found_object
assert found_object.data["type"] == "Tombstone"
end
test "ensures cache is cleared for the object" do
object = insert(:note)
cached_object = Object.get_cached_by_ap_id(object.data["id"])
assert object == cached_object
Cachex.put(:web_resp_cache, URI.parse(object.data["id"]).path, "cofe")
Object.delete(cached_object)
{:ok, nil} = Cachex.get(:object_cache, "object:#{object.data["id"]}")
{:ok, nil} = Cachex.get(:web_resp_cache, URI.parse(object.data["id"]).path)
cached_object = Object.get_cached_by_ap_id(object.data["id"])
refute object == cached_object
assert cached_object.data["type"] == "Tombstone"
end
end
describe "delete attachments" do
setup do: clear_config([Pleroma.Upload])
setup do: clear_config([:instance, :cleanup_attachments])
test "Disabled via config" do
clear_config([Pleroma.Upload, :uploader], Pleroma.Uploaders.Local)
clear_config([:instance, :cleanup_attachments], false)
file = %Plug.Upload{
content_type: "image/jpeg",
path: Path.absname("test/fixtures/image.jpg"),
filename: "an_image.jpg"
}
user = insert(:user)
{:ok, %Object{} = attachment} =
Pleroma.Web.ActivityPub.ActivityPub.upload(file, actor: user.ap_id)
%{data: %{"attachment" => [%{"url" => [%{"href" => href}]}]}} =
note = insert(:note, %{user: user, data: %{"attachment" => [attachment.data]}})
uploads_dir = Pleroma.Config.get!([Pleroma.Uploaders.Local, :uploads])
path = href |> Path.dirname() |> Path.basename()
assert {:ok, ["an_image.jpg"]} == File.ls("#{uploads_dir}/#{path}")
Object.delete(note)
ObanHelpers.perform(all_enqueued(worker: Pleroma.Workers.AttachmentsCleanupWorker))
assert Object.get_by_id(note.id).data["deleted"]
refute Object.get_by_id(attachment.id) == nil
assert {:ok, ["an_image.jpg"]} == File.ls("#{uploads_dir}/#{path}")
end
test "in subdirectories" do
clear_config([Pleroma.Upload, :uploader], Pleroma.Uploaders.Local)
clear_config([:instance, :cleanup_attachments], true)
file = %Plug.Upload{
content_type: "image/jpeg",
path: Path.absname("test/fixtures/image.jpg"),
filename: "an_image.jpg"
}
user = insert(:user)
{:ok, %Object{} = attachment} =
Pleroma.Web.ActivityPub.ActivityPub.upload(file, actor: user.ap_id)
%{data: %{"attachment" => [%{"url" => [%{"href" => href}]}]}} =
note = insert(:note, %{user: user, data: %{"attachment" => [attachment.data]}})
uploads_dir = Pleroma.Config.get!([Pleroma.Uploaders.Local, :uploads])
path = href |> Path.dirname() |> Path.basename()
assert {:ok, ["an_image.jpg"]} == File.ls("#{uploads_dir}/#{path}")
Object.delete(note)
ObanHelpers.perform(all_enqueued(worker: Pleroma.Workers.AttachmentsCleanupWorker))
assert Object.get_by_id(note.id).data["deleted"]
assert Object.get_by_id(attachment.id) == nil
assert {:ok, []} == File.ls("#{uploads_dir}/#{path}")
end
test "with dedupe enabled" do
clear_config([Pleroma.Upload, :uploader], Pleroma.Uploaders.Local)
clear_config([Pleroma.Upload, :filters], [Pleroma.Upload.Filter.Dedupe])
clear_config([:instance, :cleanup_attachments], true)
uploads_dir = Pleroma.Config.get!([Pleroma.Uploaders.Local, :uploads])
File.mkdir_p!(uploads_dir)
file = %Plug.Upload{
content_type: "image/jpeg",
path: Path.absname("test/fixtures/image.jpg"),
filename: "an_image.jpg"
}
user = insert(:user)
{:ok, %Object{} = attachment} =
Pleroma.Web.ActivityPub.ActivityPub.upload(file, actor: user.ap_id)
%{data: %{"attachment" => [%{"url" => [%{"href" => href}]}]}} =
note = insert(:note, %{user: user, data: %{"attachment" => [attachment.data]}})
filename = Path.basename(href)
assert {:ok, files} = File.ls(uploads_dir)
assert filename in files
Object.delete(note)
ObanHelpers.perform(all_enqueued(worker: Pleroma.Workers.AttachmentsCleanupWorker))
assert Object.get_by_id(note.id).data["deleted"]
assert Object.get_by_id(attachment.id) == nil
assert {:ok, files} = File.ls(uploads_dir)
refute filename in files
end
test "with objects that have legacy data.url attribute" do
clear_config([Pleroma.Upload, :uploader], Pleroma.Uploaders.Local)
clear_config([:instance, :cleanup_attachments], true)
file = %Plug.Upload{
content_type: "image/jpeg",
path: Path.absname("test/fixtures/image.jpg"),
filename: "an_image.jpg"
}
user = insert(:user)
{:ok, %Object{} = attachment} =
Pleroma.Web.ActivityPub.ActivityPub.upload(file, actor: user.ap_id)
{:ok, %Object{}} = Object.create(%{url: "https://google.com", actor: user.ap_id})
%{data: %{"attachment" => [%{"url" => [%{"href" => href}]}]}} =
note = insert(:note, %{user: user, data: %{"attachment" => [attachment.data]}})
uploads_dir = Pleroma.Config.get!([Pleroma.Uploaders.Local, :uploads])
path = href |> Path.dirname() |> Path.basename()
assert {:ok, ["an_image.jpg"]} == File.ls("#{uploads_dir}/#{path}")
Object.delete(note)
ObanHelpers.perform(all_enqueued(worker: Pleroma.Workers.AttachmentsCleanupWorker))
assert Object.get_by_id(note.id).data["deleted"]
assert Object.get_by_id(attachment.id) == nil
assert {:ok, []} == File.ls("#{uploads_dir}/#{path}")
end
test "With custom base_url" do
clear_config([Pleroma.Upload, :uploader], Pleroma.Uploaders.Local)
clear_config([Pleroma.Upload, :base_url], "https://sub.domain.tld/dir/")
clear_config([:instance, :cleanup_attachments], true)
file = %Plug.Upload{
content_type: "image/jpeg",
path: Path.absname("test/fixtures/image.jpg"),
filename: "an_image.jpg"
}
user = insert(:user)
{:ok, %Object{} = attachment} =
Pleroma.Web.ActivityPub.ActivityPub.upload(file, actor: user.ap_id)
%{data: %{"attachment" => [%{"url" => [%{"href" => href}]}]}} =
note = insert(:note, %{user: user, data: %{"attachment" => [attachment.data]}})
uploads_dir = Pleroma.Config.get!([Pleroma.Uploaders.Local, :uploads])
path = href |> Path.dirname() |> Path.basename()
assert {:ok, ["an_image.jpg"]} == File.ls("#{uploads_dir}/#{path}")
Object.delete(note)
ObanHelpers.perform(all_enqueued(worker: Pleroma.Workers.AttachmentsCleanupWorker))
assert Object.get_by_id(note.id).data["deleted"]
assert Object.get_by_id(attachment.id) == nil
assert {:ok, []} == File.ls("#{uploads_dir}/#{path}")
end
end
describe "normalizer" do
@url "http://mastodon.example.org/@admin/99541947525187367"
test "does not fetch unknown objects by default" do
assert nil == Object.normalize(@url)
end
test "fetches unknown objects when fetch is explicitly true" do
%Object{} = object = Object.normalize(@url, fetch: true)
assert object.data["url"] == @url
end
test "does not fetch unknown objects when fetch is false" do
assert is_nil(
Object.normalize(@url,
fetch: false
)
)
end
end
- describe "get_by_id_and_maybe_refetch" do
- setup do
- mock(fn
- %{method: :get, url: "https://patch.cx/objects/9a172665-2bc5-452d-8428-2361d4c33b1d"} ->
- %Tesla.Env{
- status: 200,
- body: File.read!("test/fixtures/tesla_mock/poll_original.json"),
- headers: HttpRequestMock.activitypub_object_headers()
- }
-
- env ->
- apply(HttpRequestMock, :request, [env])
- end)
-
- mock_modified = fn resp ->
- mock(fn
- %{method: :get, url: "https://patch.cx/objects/9a172665-2bc5-452d-8428-2361d4c33b1d"} ->
- resp
-
- env ->
- apply(HttpRequestMock, :request, [env])
- end)
- end
-
- on_exit(fn -> mock(fn env -> apply(HttpRequestMock, :request, [env]) end) end)
-
- [mock_modified: mock_modified]
- end
-
- test "refetches if the time since the last refetch is greater than the interval", %{
- mock_modified: mock_modified
- } do
- %Object{} =
- object =
- Object.normalize("https://patch.cx/objects/9a172665-2bc5-452d-8428-2361d4c33b1d",
- fetch: true
- )
-
- Object.set_cache(object)
-
- assert Enum.at(object.data["oneOf"], 0)["replies"]["totalItems"] == 4
- assert Enum.at(object.data["oneOf"], 1)["replies"]["totalItems"] == 0
-
- mock_modified.(%Tesla.Env{
- status: 200,
- body: File.read!("test/fixtures/tesla_mock/poll_modified.json"),
- headers: HttpRequestMock.activitypub_object_headers()
- })
-
- updated_object = Object.get_by_id_and_maybe_refetch(object.id, interval: -1)
- object_in_cache = Object.get_cached_by_ap_id(object.data["id"])
- assert updated_object == object_in_cache
- assert Enum.at(updated_object.data["oneOf"], 0)["replies"]["totalItems"] == 8
- assert Enum.at(updated_object.data["oneOf"], 1)["replies"]["totalItems"] == 3
- end
-
- test "returns the old object if refetch fails", %{mock_modified: mock_modified} do
- %Object{} =
- object =
- Object.normalize("https://patch.cx/objects/9a172665-2bc5-452d-8428-2361d4c33b1d",
- fetch: true
- )
-
- Object.set_cache(object)
-
- assert Enum.at(object.data["oneOf"], 0)["replies"]["totalItems"] == 4
- assert Enum.at(object.data["oneOf"], 1)["replies"]["totalItems"] == 0
-
- assert capture_log(fn ->
- mock_modified.(%Tesla.Env{status: 404, body: ""})
-
- updated_object = Object.get_by_id_and_maybe_refetch(object.id, interval: -1)
- object_in_cache = Object.get_cached_by_ap_id(object.data["id"])
- assert updated_object == object_in_cache
- assert Enum.at(updated_object.data["oneOf"], 0)["replies"]["totalItems"] == 4
- assert Enum.at(updated_object.data["oneOf"], 1)["replies"]["totalItems"] == 0
- end) =~
- "[error] Couldn't refresh https://patch.cx/objects/9a172665-2bc5-452d-8428-2361d4c33b1d"
- end
-
- test "does not refetch if the time since the last refetch is greater than the interval", %{
- mock_modified: mock_modified
- } do
- %Object{} =
- object =
- Object.normalize("https://patch.cx/objects/9a172665-2bc5-452d-8428-2361d4c33b1d",
- fetch: true
- )
-
- Object.set_cache(object)
-
- assert Enum.at(object.data["oneOf"], 0)["replies"]["totalItems"] == 4
- assert Enum.at(object.data["oneOf"], 1)["replies"]["totalItems"] == 0
-
- mock_modified.(%Tesla.Env{
- status: 200,
- body: File.read!("test/fixtures/tesla_mock/poll_modified.json"),
- headers: HttpRequestMock.activitypub_object_headers()
- })
-
- updated_object = Object.get_by_id_and_maybe_refetch(object.id, interval: 100)
- object_in_cache = Object.get_cached_by_ap_id(object.data["id"])
- assert updated_object == object_in_cache
- assert Enum.at(updated_object.data["oneOf"], 0)["replies"]["totalItems"] == 4
- assert Enum.at(updated_object.data["oneOf"], 1)["replies"]["totalItems"] == 0
- end
-
- test "preserves internal fields on refetch", %{mock_modified: mock_modified} do
- %Object{} =
- object =
- Object.normalize("https://patch.cx/objects/9a172665-2bc5-452d-8428-2361d4c33b1d",
- fetch: true
- )
-
- Object.set_cache(object)
-
- assert Enum.at(object.data["oneOf"], 0)["replies"]["totalItems"] == 4
- assert Enum.at(object.data["oneOf"], 1)["replies"]["totalItems"] == 0
-
- user = insert(:user)
- activity = Activity.get_create_by_object_ap_id(object.data["id"])
- {:ok, activity} = CommonAPI.favorite(activity.id, user)
- object = Object.get_by_ap_id(activity.data["object"])
-
- assert object.data["like_count"] == 1
-
- mock_modified.(%Tesla.Env{
- status: 200,
- body: File.read!("test/fixtures/tesla_mock/poll_modified.json"),
- headers: HttpRequestMock.activitypub_object_headers()
- })
-
- updated_object = Object.get_by_id_and_maybe_refetch(object.id, interval: -1)
- object_in_cache = Object.get_cached_by_ap_id(object.data["id"])
- assert updated_object == object_in_cache
- assert Enum.at(updated_object.data["oneOf"], 0)["replies"]["totalItems"] == 8
- assert Enum.at(updated_object.data["oneOf"], 1)["replies"]["totalItems"] == 3
-
- assert updated_object.data["like_count"] == 1
- end
- end
-
describe ":hashtags association" do
test "Hashtag records are created with Object record and updated on its change" do
user = insert(:user)
{:ok, %{object: object}} =
CommonAPI.post(user, %{status: "some text #hashtag1 #hashtag2 ..."})
assert [%Hashtag{name: "hashtag1"}, %Hashtag{name: "hashtag2"}] =
Enum.sort_by(object.hashtags, & &1.name)
{:ok, object} = Object.update_data(object, %{"tag" => []})
assert [] = object.hashtags
object = Object.get_by_id(object.id) |> Repo.preload(:hashtags)
assert [] = object.hashtags
{:ok, object} = Object.update_data(object, %{"tag" => ["abc", "def"]})
assert [%Hashtag{name: "abc"}, %Hashtag{name: "def"}] =
Enum.sort_by(object.hashtags, & &1.name)
end
end
describe "get_emoji_reactions/1" do
test "3-tuple current format" do
object = %Object{
data: %{
"reactions" => [
["x", ["https://some/user"], "https://some/emoji"]
]
}
}
assert Object.get_emoji_reactions(object) == object.data["reactions"]
end
test "2-tuple legacy format" do
object = %Object{
data: %{
"reactions" => [
["x", ["https://some/user"]]
]
}
}
assert Object.get_emoji_reactions(object) == [["x", ["https://some/user"], nil]]
end
test "Map format" do
object = %Object{
data: %{
"reactions" => %{
"x" => ["https://some/user"]
}
}
}
assert Object.get_emoji_reactions(object) == [["x", ["https://some/user"], nil]]
end
end
end
diff --git a/test/pleroma/web/mastodon_api/controllers/poll_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/poll_controller_test.exs
index 7912b1d5f..51af87742 100644
--- a/test/pleroma/web/mastodon_api/controllers/poll_controller_test.exs
+++ b/test/pleroma/web/mastodon_api/controllers/poll_controller_test.exs
@@ -1,242 +1,270 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.PollControllerTest do
+ use Oban.Testing, repo: Pleroma.Repo
use Pleroma.Web.ConnCase, async: true
alias Pleroma.Object
alias Pleroma.Web.CommonAPI
import Pleroma.Factory
describe "GET /api/v1/polls/:id" do
setup do: oauth_access(["read:statuses"])
test "returns poll entity for object id", %{user: user, conn: conn} do
{:ok, activity} =
CommonAPI.post(user, %{
status: "Pleroma does",
poll: %{options: ["what Mastodon't", "n't what Mastodoes"], expires_in: 20}
})
object = Object.normalize(activity, fetch: false)
conn = get(conn, "/api/v1/polls/#{object.id}")
response = json_response_and_validate_schema(conn, 200)
id = to_string(object.id)
assert %{"id" => ^id, "expired" => false, "multiple" => false} = response
+
+ # Local activities should not generate an Oban job to refresh
+ assert activity.local
+
+ refute_enqueued(
+ worker: Pleroma.Workers.PollWorker,
+ args: %{"op" => "refresh", "activity_id" => activity.id}
+ )
+ end
+
+ test "creates an oban job to refresh poll if activity is remote", %{conn: conn} do
+ user = insert(:user, local: false)
+ question = insert(:question, user: user)
+ activity = insert(:question_activity, question: question, local: false)
+
+ # Ensure this is not represented as a local activity
+ refute activity.local
+
+ object = Object.normalize(activity, fetch: false)
+
+ get(conn, "/api/v1/polls/#{object.id}")
+ |> json_response_and_validate_schema(200)
+
+ assert_enqueued(
+ worker: Pleroma.Workers.PollWorker,
+ args: %{"op" => "refresh", "activity_id" => activity.id}
+ )
end
test "does not expose polls for private statuses", %{conn: conn} do
other_user = insert(:user)
{:ok, activity} =
CommonAPI.post(other_user, %{
status: "Pleroma does",
poll: %{options: ["what Mastodon't", "n't what Mastodoes"], expires_in: 20},
visibility: "private"
})
object = Object.normalize(activity, fetch: false)
conn = get(conn, "/api/v1/polls/#{object.id}")
assert json_response_and_validate_schema(conn, 404)
end
end
test "own_votes" do
%{conn: conn} = oauth_access(["write:statuses", "read:statuses"])
other_user = insert(:user)
{:ok, activity} =
CommonAPI.post(other_user, %{
status: "A very delicious sandwich",
poll: %{
options: ["Lettuce", "Grilled Bacon", "Tomato"],
expires_in: 20,
multiple: true
}
})
object = Object.normalize(activity, fetch: false)
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0, 2]})
|> json_response_and_validate_schema(200)
object = Object.get_by_id(object.id)
assert [
%{
"name" => "Lettuce",
"replies" => %{"totalItems" => 1, "type" => "Collection"},
"type" => "Note"
},
%{
"name" => "Grilled Bacon",
"replies" => %{"totalItems" => 0, "type" => "Collection"},
"type" => "Note"
},
%{
"name" => "Tomato",
"replies" => %{"totalItems" => 1, "type" => "Collection"},
"type" => "Note"
}
] == object.data["anyOf"]
assert %{"replies" => %{"totalItems" => 0}} =
Enum.find(object.data["anyOf"], fn %{"name" => name} -> name == "Grilled Bacon" end)
Enum.each(["Lettuce", "Tomato"], fn title ->
%{"replies" => %{"totalItems" => total_items}} =
Enum.find(object.data["anyOf"], fn %{"name" => name} -> name == title end)
assert total_items == 1
end)
assert %{
"own_votes" => own_votes,
"voted" => true
} =
conn
|> get("/api/v1/polls/#{object.id}")
|> json_response_and_validate_schema(200)
assert 0 in own_votes
assert 2 in own_votes
# for non authenticated user
response =
build_conn()
|> get("/api/v1/polls/#{object.id}")
|> json_response_and_validate_schema(200)
refute Map.has_key?(response, "own_votes")
refute Map.has_key?(response, "voted")
end
describe "POST /api/v1/polls/:id/votes" do
setup do: oauth_access(["write:statuses"])
test "votes are added to the poll", %{conn: conn} do
other_user = insert(:user)
{:ok, activity} =
CommonAPI.post(other_user, %{
status: "A very delicious sandwich",
poll: %{
options: ["Lettuce", "Grilled Bacon", "Tomato"],
expires_in: 20,
multiple: true
}
})
object = Object.normalize(activity, fetch: false)
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0, 1, 2]})
|> json_response_and_validate_schema(200)
object = Object.get_by_id(object.id)
assert Enum.all?(object.data["anyOf"], fn %{"replies" => %{"totalItems" => total_items}} ->
total_items == 1
end)
end
test "author can't vote", %{user: user, conn: conn} do
{:ok, activity} =
CommonAPI.post(user, %{
status: "Am I cute?",
poll: %{options: ["Yes", "No"], expires_in: 20}
})
object = Object.normalize(activity, fetch: false)
assert conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [1]})
|> json_response_and_validate_schema(422) == %{"error" => "Poll's author can't vote"}
object = Object.get_by_id(object.id)
refute Enum.at(object.data["oneOf"], 1)["replies"]["totalItems"] == 1
end
test "does not allow multiple choices on a single-choice question", %{conn: conn} do
other_user = insert(:user)
{:ok, activity} =
CommonAPI.post(other_user, %{
status: "The glass is",
poll: %{options: ["half empty", "half full"], expires_in: 20}
})
object = Object.normalize(activity, fetch: false)
assert conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0, 1]})
|> json_response_and_validate_schema(422) == %{"error" => "Too many choices"}
object = Object.get_by_id(object.id)
refute Enum.any?(object.data["oneOf"], fn %{"replies" => %{"totalItems" => total_items}} ->
total_items == 1
end)
end
test "does not allow choice index to be greater than options count", %{conn: conn} do
other_user = insert(:user)
{:ok, activity} =
CommonAPI.post(other_user, %{
status: "Am I cute?",
poll: %{options: ["Yes", "No"], expires_in: 20}
})
object = Object.normalize(activity, fetch: false)
conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [2]})
assert json_response_and_validate_schema(conn, 422) == %{"error" => "Invalid indices"}
end
test "returns 404 error when object is not exist", %{conn: conn} do
conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/polls/1/votes", %{"choices" => [0]})
assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"}
end
test "returns 404 when poll is private and not available for user", %{conn: conn} do
other_user = insert(:user)
{:ok, activity} =
CommonAPI.post(other_user, %{
status: "Am I cute?",
poll: %{options: ["Yes", "No"], expires_in: 20},
visibility: "private"
})
object = Object.normalize(activity, fetch: false)
conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0]})
assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"}
end
end
end
diff --git a/test/pleroma/workers/poll_worker_test.exs b/test/pleroma/workers/poll_worker_test.exs
index 749df8aff..a7cbbdb83 100644
--- a/test/pleroma/workers/poll_worker_test.exs
+++ b/test/pleroma/workers/poll_worker_test.exs
@@ -1,49 +1,108 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Workers.PollWorkerTest do
use Pleroma.DataCase
use Oban.Testing, repo: Pleroma.Repo
import Mock
import Pleroma.Factory
alias Pleroma.Workers.PollWorker
- test "poll notification job" do
+ test "local poll ending notification job" do
user = insert(:user)
question = insert(:question, user: user)
- activity = insert(:question_activity, question: question)
+ activity = insert(:question_activity, question: question, user: user)
PollWorker.schedule_poll_end(activity)
expected_job_args = %{"activity_id" => activity.id, "op" => "poll_end"}
assert_enqueued(args: expected_job_args)
with_mocks([
{
Pleroma.Web.Streamer,
[],
[
stream: fn _, _ -> nil end
]
},
{
Pleroma.Web.Push,
[],
[
send: fn _ -> nil end
]
}
]) do
[job] = all_enqueued(worker: PollWorker)
PollWorker.perform(job)
# Ensure notifications were streamed out when job executes
assert called(Pleroma.Web.Streamer.stream(["user", "user:notification"], :_))
assert called(Pleroma.Web.Push.send(:_))
+
+ # Skip refreshing polls for local activities
+ assert activity.local
+
+ refute_enqueued(
+ worker: PollWorker,
+ args: %{"op" => "refresh", "activity_id" => activity.id}
+ )
+ end
+ end
+
+ test "remote poll ending notification job schedules refresh" do
+ user = insert(:user, local: false)
+ question = insert(:question, user: user)
+ activity = insert(:question_activity, question: question, user: user)
+
+ PollWorker.schedule_poll_end(activity)
+
+ expected_job_args = %{"activity_id" => activity.id, "op" => "poll_end"}
+
+ assert_enqueued(args: expected_job_args)
+
+ [job] = all_enqueued(worker: PollWorker)
+ PollWorker.perform(job)
+
+ refute activity.local
+
+ assert_enqueued(
+ worker: PollWorker,
+ args: %{"op" => "refresh", "activity_id" => activity.id}
+ )
+ end
+
+ test "poll refresh" do
+ user = insert(:user, local: false)
+ question = insert(:question, user: user)
+ activity = insert(:question_activity, question: question)
+
+ PollWorker.new(%{"op" => "refresh", "activity_id" => activity.id})
+ |> Oban.insert()
+
+ expected_job_args = %{"activity_id" => activity.id, "op" => "refresh"}
+
+ assert_enqueued(args: expected_job_args)
+
+ with_mocks([
+ {
+ Pleroma.Web.Streamer,
+ [],
+ [
+ stream: fn _, _ -> nil end
+ ]
+ }
+ ]) do
+ [job] = all_enqueued(worker: PollWorker)
+ PollWorker.perform(job)
+
+ # Ensure updates are streamed out
+ assert called(Pleroma.Web.Streamer.stream(["user", "list", "public", "public:local"], :_))
end
end
end
diff --git a/test/support/factory.ex b/test/support/factory.ex
index 8f1c6faf9..91e5805c8 100644
--- a/test/support/factory.ex
+++ b/test/support/factory.ex
@@ -1,669 +1,671 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Factory do
use ExMachina.Ecto, repo: Pleroma.Repo
require Pleroma.Constants
alias Pleroma.Object
alias Pleroma.User
@rsa_keys [
"test/fixtures/rsa_keys/key_1.pem",
"test/fixtures/rsa_keys/key_2.pem",
"test/fixtures/rsa_keys/key_3.pem",
"test/fixtures/rsa_keys/key_4.pem",
"test/fixtures/rsa_keys/key_5.pem"
]
|> Enum.map(&File.read!/1)
def participation_factory do
conversation = insert(:conversation)
user = insert(:user)
%Pleroma.Conversation.Participation{
conversation: conversation,
user: user,
read: false
}
end
def conversation_factory do
%Pleroma.Conversation{
ap_id: sequence(:ap_id, &"https://some_conversation/#{&1}")
}
end
def user_factory(attrs \\ %{}) do
pem = Enum.random(@rsa_keys)
user = %User{
name: sequence(:name, &"Test テスト User #{&1}"),
email: sequence(:email, &"user#{&1}@example.com"),
nickname: sequence(:nickname, &"nick#{&1}"),
password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test"),
bio: sequence(:bio, &"Tester Number #{&1}"),
is_discoverable: true,
last_digest_emailed_at: NaiveDateTime.utc_now(),
last_refreshed_at: NaiveDateTime.utc_now(),
notification_settings: %Pleroma.User.NotificationSetting{},
multi_factor_authentication_settings: %Pleroma.MFA.Settings{},
keys: pem
}
user
|> Map.put(:raw_bio, user.bio)
|> merge_attributes(Map.delete(attrs, :domain))
|> make_user_urls(attrs)
end
defp make_user_urls(user, attrs) do
urls =
if attrs[:local] == false do
base_domain = attrs[:domain] || Enum.random(["domain1.com", "domain2.com", "domain3.com"])
ap_id = "https://#{base_domain}/users/#{user.nickname}"
%{
ap_id: attrs[:ap_id] || ap_id,
follower_address: attrs[:follower_address] || ap_id <> "/followers",
following_address: attrs[:following_address] || ap_id <> "/following",
featured_address: attrs[:featured_address] || ap_id <> "/collections/featured",
inbox: attrs[:inbox] || "https://#{base_domain}/inbox"
}
else
%{
ap_id: attrs[:ap_id] || User.ap_id(user),
follower_address: attrs[:follower_address] || User.ap_followers(user),
following_address: attrs[:following_address] || User.ap_following(user),
featured_address: attrs[:featured_address] || User.ap_featured_collection(user)
}
end
Map.merge(user, urls)
end
def user_relationship_factory(attrs \\ %{}) do
source = attrs[:source] || insert(:user)
target = attrs[:target] || insert(:user)
relationship_type = attrs[:relationship_type] || :block
%Pleroma.UserRelationship{
source_id: source.id,
target_id: target.id,
relationship_type: relationship_type
}
end
def note_factory(attrs \\ %{}) do
text = sequence(:text, &"This is :moominmamma: note #{&1}")
user = attrs[:user] || insert(:user)
data = %{
"type" => "Note",
"content" => text,
"source" => text,
"id" => Pleroma.Web.ActivityPub.Utils.generate_object_id(),
"actor" => user.ap_id,
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"published" => DateTime.utc_now() |> DateTime.to_iso8601(),
"likes" => [],
"like_count" => 0,
"context" => "2hu",
"summary" => "2hu",
"tag" => ["2hu"],
"emoji" => %{
"2hu" => "corndog.png"
}
}
%Pleroma.Object{
data: merge_attributes(data, Map.get(attrs, :data, %{}))
}
end
def attachment_factory(attrs \\ %{}) do
user = attrs[:user] || insert(:user)
data =
attachment_data(user.ap_id, nil)
|> Map.put("id", Pleroma.Web.ActivityPub.Utils.generate_object_id())
%Pleroma.Object{
data: merge_attributes(data, Map.get(attrs, :data, %{}))
}
end
def attachment_note_factory(attrs \\ %{}) do
user = attrs[:user] || insert(:user)
{length, attrs} = Map.pop(attrs, :length, 1)
data = %{
"attachment" =>
Stream.repeatedly(fn -> attachment_data(user.ap_id, attrs[:href]) end)
|> Enum.take(length)
}
build(:note, Map.put(attrs, :data, data))
end
defp attachment_data(ap_id, href) do
href = href || sequence(:href, &"#{Pleroma.Web.Endpoint.url()}/media/#{&1}.jpg")
%{
"url" => [
%{
"href" => href,
"type" => "Link",
"mediaType" => "image/jpeg"
}
],
"name" => "some name",
"type" => "Document",
"actor" => ap_id,
"mediaType" => "image/jpeg"
}
end
def followers_only_note_factory(attrs \\ %{}) do
%Pleroma.Object{data: data} = note_factory(attrs)
%Pleroma.Object{data: Map.merge(data, %{"to" => [data["actor"] <> "/followers"]})}
end
def audio_factory(attrs \\ %{}) do
text = sequence(:text, &"lain radio episode #{&1}")
user = attrs[:user] || insert(:user)
data = %{
"type" => "Audio",
"id" => Pleroma.Web.ActivityPub.Utils.generate_object_id(),
"artist" => "lain",
"title" => text,
"album" => "lain radio",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"published" => DateTime.utc_now() |> DateTime.to_iso8601(),
"actor" => user.ap_id,
"length" => 180_000
}
%Pleroma.Object{
data: merge_attributes(data, Map.get(attrs, :data, %{}))
}
end
def listen_factory do
audio = insert(:audio)
data = %{
"id" => Pleroma.Web.ActivityPub.Utils.generate_activity_id(),
"type" => "Listen",
"actor" => audio.data["actor"],
"to" => audio.data["to"],
"object" => audio.data,
"published" => audio.data["published"]
}
%Pleroma.Activity{
data: data,
actor: data["actor"],
recipients: data["to"]
}
end
def direct_note_factory do
user2 = insert(:user, local: false, inbox: "http://example.com/inbox")
%Pleroma.Object{data: data} = note_factory()
%Pleroma.Object{data: Map.merge(data, %{"to" => [user2.ap_id]})}
end
def article_factory do
%Pleroma.Object{data: data} = note_factory()
%Pleroma.Object{data: Map.merge(data, %{"type" => "Article"})}
end
def tombstone_factory do
data = %{
"type" => "Tombstone",
"id" => Pleroma.Web.ActivityPub.Utils.generate_object_id(),
"formerType" => "Note",
"deleted" => DateTime.utc_now() |> DateTime.to_iso8601()
}
%Pleroma.Object{
data: data
}
end
def question_factory(attrs \\ %{}) do
user = attrs[:user] || insert(:user)
+ closed = attrs[:closed] || DateTime.utc_now() |> DateTime.add(86_400) |> DateTime.to_iso8601()
data = %{
"id" => Pleroma.Web.ActivityPub.Utils.generate_object_id(),
"type" => "Question",
"actor" => user.ap_id,
"attributedTo" => user.ap_id,
"attachment" => [],
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [user.follower_address],
"context" => Pleroma.Web.ActivityPub.Utils.generate_context_id(),
- "closed" => DateTime.utc_now() |> DateTime.add(86_400) |> DateTime.to_iso8601(),
+ "closed" => closed,
"content" => "Which flavor of ice cream do you prefer?",
"oneOf" => [
%{
"type" => "Note",
"name" => "chocolate",
"replies" => %{"totalItems" => 0, "type" => "Collection"}
},
%{
"type" => "Note",
"name" => "vanilla",
"replies" => %{"totalItems" => 0, "type" => "Collection"}
}
]
}
%Pleroma.Object{
data: merge_attributes(data, Map.get(attrs, :data, %{}))
}
end
def direct_note_activity_factory do
dm = insert(:direct_note)
data = %{
"id" => Pleroma.Web.ActivityPub.Utils.generate_activity_id(),
"type" => "Create",
"actor" => dm.data["actor"],
"to" => dm.data["to"],
"object" => dm.data,
"published" => DateTime.utc_now() |> DateTime.to_iso8601(),
"context" => dm.data["context"]
}
%Pleroma.Activity{
data: data,
actor: data["actor"],
recipients: data["to"]
}
end
def add_activity_factory(attrs \\ %{}) do
featured_collection_activity(attrs, "Add")
end
def remove_activity_factor(attrs \\ %{}) do
featured_collection_activity(attrs, "Remove")
end
defp featured_collection_activity(attrs, type) do
user = attrs[:user] || insert(:user)
note = attrs[:note] || insert(:note, user: user)
data_attrs =
attrs
|> Map.get(:data_attrs, %{})
|> Map.put(:type, type)
attrs = Map.drop(attrs, [:user, :note, :data_attrs])
data =
%{
"id" => Pleroma.Web.ActivityPub.Utils.generate_activity_id(),
"target" => user.featured_address,
"object" => note.data["object"],
"actor" => note.data["actor"],
"type" => "Add",
"to" => [Pleroma.Constants.as_public()],
"cc" => [user.follower_address]
}
|> Map.merge(data_attrs)
%Pleroma.Activity{
data: data,
actor: data["actor"],
recipients: data["to"]
}
|> Map.merge(attrs)
end
def followers_only_note_activity_factory(attrs \\ %{}) do
user = attrs[:user] || insert(:user)
note = insert(:followers_only_note, user: user)
data_attrs = attrs[:data_attrs] || %{}
attrs = Map.drop(attrs, [:user, :note, :data_attrs])
data =
%{
"id" => Pleroma.Web.ActivityPub.Utils.generate_activity_id(),
"type" => "Create",
"actor" => note.data["actor"],
"to" => note.data["to"],
"object" => note.data,
"published" => DateTime.utc_now() |> DateTime.to_iso8601(),
"context" => note.data["context"]
}
|> Map.merge(data_attrs)
%Pleroma.Activity{
data: data,
actor: data["actor"],
recipients: data["to"]
}
|> Map.merge(attrs)
end
def note_activity_factory(attrs \\ %{}) do
user = attrs[:user] || insert(:user)
note = attrs[:note] || insert(:note, user: user)
data_attrs = attrs[:data_attrs] || %{}
attrs = Map.drop(attrs, [:user, :note, :data_attrs])
data =
%{
"id" => Pleroma.Web.ActivityPub.Utils.generate_activity_id(),
"type" => "Create",
"actor" => note.data["actor"],
"to" => note.data["to"],
"object" => note.data["id"],
"published" => DateTime.utc_now() |> DateTime.to_iso8601(),
"context" => note.data["context"]
}
|> Map.merge(data_attrs)
%Pleroma.Activity{
data: data,
actor: data["actor"],
recipients: data["to"]
}
|> Map.merge(attrs)
end
def article_activity_factory do
article = insert(:article)
data = %{
"id" => Pleroma.Web.ActivityPub.Utils.generate_activity_id(),
"type" => "Create",
"actor" => article.data["actor"],
"to" => article.data["to"],
"object" => article.data,
"published" => DateTime.utc_now() |> DateTime.to_iso8601(),
"context" => article.data["context"]
}
%Pleroma.Activity{
data: data,
actor: data["actor"],
recipients: data["to"]
}
end
def announce_activity_factory(attrs \\ %{}) do
note_activity = attrs[:note_activity] || insert(:note_activity)
user = attrs[:user] || insert(:user)
data = %{
"type" => "Announce",
"actor" => note_activity.actor,
"object" => note_activity.data["id"],
"to" => [user.follower_address, note_activity.data["actor"]],
"cc" => ["https://www.w3.org/ns/activitystreams#Public"],
"context" => note_activity.data["context"]
}
%Pleroma.Activity{
data: data,
actor: user.ap_id,
recipients: data["to"]
}
end
def like_activity_factory(attrs \\ %{}) do
note_activity = attrs[:note_activity] || insert(:note_activity)
object = Object.normalize(note_activity, fetch: false)
user = insert(:user)
data =
%{
"id" => Pleroma.Web.ActivityPub.Utils.generate_activity_id(),
"actor" => user.ap_id,
"type" => "Like",
"object" => object.data["id"],
"published_at" => DateTime.utc_now() |> DateTime.to_iso8601()
}
|> Map.merge(attrs[:data_attrs] || %{})
%Pleroma.Activity{
data: data
}
end
def follow_activity_factory do
follower = insert(:user)
followed = insert(:user)
data = %{
"id" => Pleroma.Web.ActivityPub.Utils.generate_activity_id(),
"actor" => follower.ap_id,
"type" => "Follow",
"object" => followed.ap_id,
"published_at" => DateTime.utc_now() |> DateTime.to_iso8601()
}
%Pleroma.Activity{
data: data,
actor: follower.ap_id
}
end
def report_activity_factory(attrs \\ %{}) do
user = attrs[:user] || insert(:user)
activity = attrs[:activity] || insert(:note_activity)
state = attrs[:state] || "open"
data = %{
"id" => Pleroma.Web.ActivityPub.Utils.generate_activity_id(),
"actor" => user.ap_id,
"type" => "Flag",
"object" => [activity.actor, activity.data["id"]],
"published" => DateTime.utc_now() |> DateTime.to_iso8601(),
"to" => [],
"cc" => [activity.actor],
"context" => activity.data["context"],
"state" => state
}
%Pleroma.Activity{
data: data,
actor: data["actor"],
recipients: data["to"] ++ data["cc"]
}
end
def question_activity_factory(attrs \\ %{}) do
user = attrs[:user] || insert(:user)
question = attrs[:question] || insert(:question, user: user)
data_attrs = attrs[:data_attrs] || %{}
attrs = Map.drop(attrs, [:user, :question, :data_attrs])
data =
%{
"id" => Pleroma.Web.ActivityPub.Utils.generate_activity_id(),
"type" => "Create",
"actor" => question.data["actor"],
"to" => question.data["to"],
"object" => question.data["id"],
"published" => DateTime.utc_now() |> DateTime.to_iso8601(),
"context" => question.data["context"]
}
|> Map.merge(data_attrs)
%Pleroma.Activity{
data: data,
actor: data["actor"],
- recipients: data["to"]
+ recipients: data["to"],
+ local: user.local
}
|> Map.merge(attrs)
end
def oauth_app_factory do
%Pleroma.Web.OAuth.App{
client_name: sequence(:client_name, &"Some client #{&1}"),
redirect_uris: "https://example.com/callback",
scopes: ["read", "write", "follow", "push", "admin"],
website: "https://example.com",
client_id: Ecto.UUID.generate(),
client_secret: "aaa;/&bbb"
}
end
def instance_factory do
%Pleroma.Instances.Instance{
host: "domain.com",
unreachable_since: nil
}
end
def oauth_token_factory(attrs \\ %{}) do
scopes = Map.get(attrs, :scopes, ["read"])
oauth_app = Map.get_lazy(attrs, :app, fn -> insert(:oauth_app, scopes: scopes) end)
user = Map.get_lazy(attrs, :user, fn -> build(:user) end)
valid_until =
Map.get(attrs, :valid_until, NaiveDateTime.add(NaiveDateTime.utc_now(), 60 * 10))
%Pleroma.Web.OAuth.Token{
token: :crypto.strong_rand_bytes(32) |> Base.url_encode64(),
refresh_token: :crypto.strong_rand_bytes(32) |> Base.url_encode64(),
scopes: scopes,
user: user,
app: oauth_app,
valid_until: valid_until
}
end
def oauth_admin_token_factory(attrs \\ %{}) do
user = Map.get_lazy(attrs, :user, fn -> build(:user, is_admin: true) end)
scopes =
attrs
|> Map.get(:scopes, ["admin"])
|> Kernel.++(["admin"])
|> Enum.uniq()
attrs = Map.merge(attrs, %{user: user, scopes: scopes})
oauth_token_factory(attrs)
end
def oauth_authorization_factory do
%Pleroma.Web.OAuth.Authorization{
token: :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false),
scopes: ["read", "write", "follow", "push"],
valid_until: NaiveDateTime.add(NaiveDateTime.utc_now(), 60 * 10),
user: build(:user),
app: build(:oauth_app)
}
end
def push_subscription_factory do
%Pleroma.Web.Push.Subscription{
user: build(:user),
token: build(:oauth_token),
endpoint: "https://example.com/example/1234",
key_auth: "8eDyX_uCN0XRhSbY5hs7Hg==",
key_p256dh:
"BCIWgsnyXDv1VkhqL2P7YRBvdeuDnlwAPT2guNhdIoW3IP7GmHh1SMKPLxRf7x8vJy6ZFK3ol2ohgn_-0yP7QQA=",
data: %{}
}
end
def notification_factory do
%Pleroma.Notification{
user: build(:user)
}
end
def scheduled_activity_factory do
%Pleroma.ScheduledActivity{
user: build(:user),
scheduled_at: NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(60), :millisecond),
params: build(:note) |> Map.from_struct() |> Map.get(:data)
}
end
def registration_factory do
user = insert(:user)
%Pleroma.Registration{
user: user,
provider: "twitter",
uid: "171799000",
info: %{
"name" => "John Doe",
"email" => "john@doe.com",
"nickname" => "johndoe",
"description" => "My bio"
}
}
end
def config_factory(attrs \\ %{}) do
%Pleroma.ConfigDB{
key: sequence(:key, &String.to_atom("some_key_#{&1}")),
group: :pleroma,
value:
sequence(
:value,
&%{another_key: "#{&1}somevalue", another: "#{&1}somevalue"}
)
}
|> merge_attributes(attrs)
end
def marker_factory do
%Pleroma.Marker{
user: build(:user),
timeline: "notifications",
lock_version: 0,
last_read_id: "1"
}
end
def mfa_token_factory do
%Pleroma.MFA.Token{
token: :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false),
authorization: build(:oauth_authorization),
valid_until: NaiveDateTime.add(NaiveDateTime.utc_now(), 60 * 10),
user: build(:user)
}
end
def filter_factory do
%Pleroma.Filter{
user: build(:user),
filter_id: sequence(:filter_id, & &1),
phrase: "cofe",
context: ["home"]
}
end
def announcement_factory(params \\ %{}) do
data = Map.get(params, :data, %{})
{_, params} = Map.pop(params, :data)
%Pleroma.Announcement{
data: Map.merge(%{"content" => "test announcement", "all_day" => false}, data)
}
|> Map.merge(params)
|> Pleroma.Announcement.add_rendered_properties()
end
end
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Fri, Oct 30, 4:31 AM (1 d, 2 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1799448
Default Alt Text
(68 KB)
Attached To
Mode
rPUBE pleroma-upstream
Attached
Detach File
Event Timeline