Merge branch 'poll-notification' into 'develop'
authorHaelwenn <contact+git.pleroma.social@hacktivis.me>
Mon, 9 Aug 2021 10:02:37 +0000 (10:02 +0000)
committerHaelwenn <contact+git.pleroma.social@hacktivis.me>
Mon, 9 Aug 2021 10:02:37 +0000 (10:02 +0000)
MastodonAPI: Support poll notification

See merge request pleroma/pleroma!3484

17 files changed:
CHANGELOG.md
config/config.exs
lib/pleroma/notification.ex
lib/pleroma/web/activity_pub/activity_pub.ex
lib/pleroma/web/activity_pub/side_effects.ex
lib/pleroma/web/api_spec/operations/notification_operation.ex
lib/pleroma/web/mastodon_api/controllers/notification_controller.ex
lib/pleroma/web/mastodon_api/views/notification_view.ex
lib/pleroma/web/push/subscription.ex
lib/pleroma/workers/poll_worker.ex [new file with mode: 0644]
priv/repo/migrations/20210717000000_add_poll_to_notifications_enum.exs [new file with mode: 0644]
test/pleroma/notification_test.exs
test/pleroma/web/activity_pub/side_effects_test.exs
test/pleroma/web/common_api_test.exs
test/pleroma/web/mastodon_api/controllers/status_controller_test.exs
test/pleroma/web/mastodon_api/views/notification_view_test.exs
test/support/factory.ex

index 9e594f1741ef0f3136526999b55ab9425c1a513d..45a365505756860a3c5a3b272efdd5a7138b0f67 100644 (file)
@@ -35,6 +35,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - AdminAPI: return `created_at` date with users.
 - `AnalyzeMetadata` upload filter for extracting image/video attachment dimensions and generating blurhashes for images. Blurhashes for videos are not generated at this time.
 - Attachment dimensions and blurhashes are federated when available.
+- Mastodon API: support `poll` notification.
 - Pinned posts federation
 
 ### Fixed
index b50c910b12b985ed1c8f6311fc6b8a25c6f8c550..828fe00857518594c69495ae44f705c9debd8b71 100644 (file)
@@ -560,6 +560,7 @@ config :pleroma, Oban,
     mailer: 10,
     transmogrifier: 20,
     scheduled_activities: 10,
+    poll_notifications: 10,
     background: 5,
     remote_fetcher: 2,
     attachments_cleanup: 1,
index 7efbdc49afe1811a145a78cc548ad0dd54935849..32f13df69c31a6400d77156b6fdfcaba36e0289d 100644 (file)
@@ -72,6 +72,7 @@ defmodule Pleroma.Notification do
     pleroma:emoji_reaction
     pleroma:report
     reblog
+    poll
   }
 
   def changeset(%Notification{} = notification, attrs) do
@@ -379,7 +380,7 @@ defmodule Pleroma.Notification do
     notifications =
       Enum.map(potential_receivers, fn user ->
         do_send = do_send && user in enabled_receivers
-        create_notification(activity, user, do_send)
+        create_notification(activity, user, do_send: do_send)
       end)
       |> Enum.reject(&is_nil/1)
 
@@ -435,15 +436,18 @@ defmodule Pleroma.Notification do
   end
 
   # TODO move to sql, too.
-  def create_notification(%Activity{} = activity, %User{} = user, do_send \\ true) do
-    unless skip?(activity, user) do
+  def create_notification(%Activity{} = activity, %User{} = user, opts \\ []) do
+    do_send = Keyword.get(opts, :do_send, true)
+    type = Keyword.get(opts, :type, type_from_activity(activity))
+
+    unless skip?(activity, user, opts) do
       {:ok, %{notification: notification}} =
         Multi.new()
         |> Multi.insert(:notification, %Notification{
           user_id: user.id,
           activity: activity,
           seen: mark_as_read?(activity, user),
-          type: type_from_activity(activity)
+          type: type
         })
         |> Marker.multi_set_last_read_id(user, "notifications")
         |> Repo.transaction()
@@ -457,6 +461,28 @@ defmodule Pleroma.Notification do
     end
   end
 
+  def create_poll_notifications(%Activity{} = activity) do
+    with %Object{data: %{"type" => "Question", "actor" => actor} = data} <-
+           Object.normalize(activity) do
+      voters =
+        case data do
+          %{"voters" => voters} when is_list(voters) -> voters
+          _ -> []
+        end
+
+      notifications =
+        Enum.reduce([actor | voters], [], fn ap_id, acc ->
+          with %User{local: true} = user <- User.get_by_ap_id(ap_id) do
+            [create_notification(activity, user, type: "poll") | acc]
+          else
+            _ -> acc
+          end
+        end)
+
+      {:ok, notifications}
+    end
+  end
+
   @doc """
   Returns a tuple with 2 elements:
     {notification-enabled receivers, currently disabled receivers (blocking / [thread] muting)}
@@ -572,8 +598,10 @@ defmodule Pleroma.Notification do
     Enum.uniq(ap_ids) -- thread_muter_ap_ids
   end
 
-  @spec skip?(Activity.t(), User.t()) :: boolean()
-  def skip?(%Activity{} = activity, %User{} = user) do
+  def skip?(activity, user, opts \\ [])
+
+  @spec skip?(Activity.t(), User.t(), Keyword.t()) :: boolean()
+  def skip?(%Activity{} = activity, %User{} = user, opts) do
     [
       :self,
       :invisible,
@@ -581,17 +609,21 @@ defmodule Pleroma.Notification do
       :recently_followed,
       :filtered
     ]
-    |> Enum.find(&skip?(&1, activity, user))
+    |> Enum.find(&skip?(&1, activity, user, opts))
   end
 
-  def skip?(_, _), do: false
+  def skip?(_activity, _user, _opts), do: false
 
-  @spec skip?(atom(), Activity.t(), User.t()) :: boolean()
-  def skip?(:self, %Activity{} = activity, %User{} = user) do
-    activity.data["actor"] == user.ap_id
+  @spec skip?(atom(), Activity.t(), User.t(), Keyword.t()) :: boolean()
+  def skip?(:self, %Activity{} = activity, %User{} = user, opts) do
+    cond do
+      opts[:type] == "poll" -> false
+      activity.data["actor"] == user.ap_id -> true
+      true -> false
+    end
   end
 
-  def skip?(:invisible, %Activity{} = activity, _) do
+  def skip?(:invisible, %Activity{} = activity, _user, _opts) do
     actor = activity.data["actor"]
     user = User.get_cached_by_ap_id(actor)
     User.invisible?(user)
@@ -600,15 +632,27 @@ defmodule Pleroma.Notification do
   def skip?(
         :block_from_strangers,
         %Activity{} = activity,
-        %User{notification_settings: %{block_from_strangers: true}} = user
+        %User{notification_settings: %{block_from_strangers: true}} = user,
+        opts
       ) do
     actor = activity.data["actor"]
     follower = User.get_cached_by_ap_id(actor)
-    !User.following?(follower, user)
+
+    cond do
+      opts[:type] == "poll" -> false
+      user.ap_id == actor -> false
+      !User.following?(follower, user) -> true
+      true -> false
+    end
   end
 
   # To do: consider defining recency in hours and checking FollowingRelationship with a single SQL
-  def skip?(:recently_followed, %Activity{data: %{"type" => "Follow"}} = activity, %User{} = user) do
+  def skip?(
+        :recently_followed,
+        %Activity{data: %{"type" => "Follow"}} = activity,
+        %User{} = user,
+        _opts
+      ) do
     actor = activity.data["actor"]
 
     Notification.for_user(user)
@@ -618,9 +662,10 @@ defmodule Pleroma.Notification do
     end)
   end
 
-  def skip?(:filtered, %{data: %{"type" => type}}, _) when type in ["Follow", "Move"], do: false
+  def skip?(:filtered, %{data: %{"type" => type}}, _user, _opts) when type in ["Follow", "Move"],
+    do: false
 
-  def skip?(:filtered, activity, user) do
+  def skip?(:filtered, activity, user, _opts) do
     object = Object.normalize(activity, fetch: false)
 
     cond do
@@ -638,7 +683,7 @@ defmodule Pleroma.Notification do
     end
   end
 
-  def skip?(_, _, _), do: false
+  def skip?(_type, _activity, _user, _opts), do: false
 
   def mark_as_read?(activity, target_user) do
     user = Activity.user_actor(activity)
index 4c29dda355534fcae437700e130e4128ed0c515e..19961a4a55ea5b9370922d1df63ca0b9330fe00a 100644 (file)
@@ -25,6 +25,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
   alias Pleroma.Web.Streamer
   alias Pleroma.Web.WebFinger
   alias Pleroma.Workers.BackgroundWorker
+  alias Pleroma.Workers.PollWorker
 
   import Ecto.Query
   import Pleroma.Web.ActivityPub.Utils
@@ -288,6 +289,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
          {:quick_insert, false, activity} <- {:quick_insert, quick_insert?, activity},
          {:ok, _actor} <- increase_note_count_if_public(actor, activity),
          _ <- notify_and_stream(activity),
+         :ok <- maybe_schedule_poll_notifications(activity),
          :ok <- maybe_federate(activity) do
       {:ok, activity}
     else
@@ -302,6 +304,11 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
     end
   end
 
+  defp maybe_schedule_poll_notifications(activity) do
+    PollWorker.schedule_poll_end(activity)
+    :ok
+  end
+
   @spec listen(map()) :: {:ok, Activity.t()} | {:error, any()}
   def listen(%{to: to, actor: actor, context: context, object: object} = params) do
     additional = params[:additional] || %{}
index b0ec84ade17cb44309759b0de73861c114159036..dda48ea5f2e1d67d4257b1ff8d8a2f96ded26249 100644 (file)
@@ -24,6 +24,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
   alias Pleroma.Web.ActivityPub.Utils
   alias Pleroma.Web.Push
   alias Pleroma.Web.Streamer
+  alias Pleroma.Workers.PollWorker
 
   require Logger
 
@@ -195,7 +196,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
   # - Set up notifications
   @impl true
   def handle(%{data: %{"type" => "Create"}} = activity, meta) do
-    with {:ok, object, meta} <- handle_object_creation(meta[:object_data], meta),
+    with {:ok, object, meta} <- handle_object_creation(meta[:object_data], activity, meta),
          %User{} = user <- User.get_cached_by_ap_id(activity.data["actor"]) do
       {:ok, notifications} = Notification.create_notifications(activity, do_send: false)
       {:ok, _user} = ActivityPub.increase_note_count_if_public(user, object)
@@ -389,7 +390,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
     {:ok, object, meta}
   end
 
-  def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do
+  def handle_object_creation(%{"type" => "ChatMessage"} = object, _activity, meta) do
     with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do
       actor = User.get_cached_by_ap_id(object.data["actor"])
       recipient = User.get_cached_by_ap_id(hd(object.data["to"]))
@@ -424,7 +425,14 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
     end
   end
 
-  def handle_object_creation(%{"type" => "Answer"} = object_map, meta) do
+  def handle_object_creation(%{"type" => "Question"} = object, activity, meta) do
+    with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do
+      PollWorker.schedule_poll_end(activity)
+      {:ok, object, meta}
+    end
+  end
+
+  def handle_object_creation(%{"type" => "Answer"} = object_map, _activity, meta) do
     with {:ok, object, meta} <- Pipeline.common_pipeline(object_map, meta) do
       Object.increase_vote_count(
         object.data["inReplyTo"],
@@ -436,15 +444,15 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
     end
   end
 
-  def handle_object_creation(%{"type" => objtype} = object, meta)
-      when objtype in ~w[Audio Video Question Event Article Note Page] do
+  def handle_object_creation(%{"type" => objtype} = object, _activity, meta)
+      when objtype in ~w[Audio Video Event Article Note Page] do
     with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do
       {:ok, object, meta}
     end
   end
 
   # Nothing to do
-  def handle_object_creation(object, meta) do
+  def handle_object_creation(object, _activity, meta) do
     {:ok, object, meta}
   end
 
index ec88eabe1f28d65f57bd590de6eaab77b811a2bc..e4ce42f1cf91540c2c0963950b0b255259551db9 100644 (file)
@@ -195,7 +195,8 @@ defmodule Pleroma.Web.ApiSpec.NotificationOperation do
         "pleroma:chat_mention",
         "pleroma:report",
         "move",
-        "follow_request"
+        "follow_request",
+        "poll"
       ],
       description: """
       The type of event that resulted in the notification.
index 647ba661e7b917151eb904e78a1644dedc0f9eb6..002d6b2cea48762b61eabc927cde7adfacc7da57 100644 (file)
@@ -50,6 +50,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do
     favourite
     move
     pleroma:emoji_reaction
+    poll
   }
   def index(%{assigns: %{user: user}} = conn, params) do
     params =
index df9bedfed478b894a1b22bde6eb7912af7b3972b..35c636d4e8c24679ae5be3a5c87ecbd703d49272 100644 (file)
@@ -112,6 +112,9 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
       "move" ->
         put_target(response, activity, reading_user, %{})
 
+      "poll" ->
+        put_status(response, activity, reading_user, status_render_opts)
+
       "pleroma:emoji_reaction" ->
         response
         |> put_status(parent_activity_fn.(), reading_user, status_render_opts)
index 4f6c9bc9f731397794212ffb85103cc888cffe73..35bf2e2236dc61f382e5684ed3ef7946a9e9ff4e 100644 (file)
@@ -26,7 +26,7 @@ defmodule Pleroma.Web.Push.Subscription do
   end
 
   # credo:disable-for-next-line Credo.Check.Readability.MaxLineLength
-  @supported_alert_types ~w[follow favourite mention reblog pleroma:chat_mention pleroma:emoji_reaction]a
+  @supported_alert_types ~w[follow favourite mention reblog poll pleroma:chat_mention pleroma:emoji_reaction]a
 
   defp alerts(%{data: %{alerts: alerts}}) do
     alerts = Map.take(alerts, @supported_alert_types)
diff --git a/lib/pleroma/workers/poll_worker.ex b/lib/pleroma/workers/poll_worker.ex
new file mode 100644 (file)
index 0000000..3423cc8
--- /dev/null
@@ -0,0 +1,45 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2021 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 Pleroma.Workers.WorkerHelper, queue: "poll_notifications"
+
+  alias Pleroma.Activity
+  alias Pleroma.Notification
+  alias Pleroma.Object
+
+  @impl Oban.Worker
+  def perform(%Job{args: %{"op" => "poll_end", "activity_id" => activity_id}}) do
+    with %Activity{} = activity <- find_poll_activity(activity_id) do
+      Notification.create_poll_notifications(activity)
+    end
+  end
+
+  defp find_poll_activity(activity_id) do
+    with nil <- Activity.get_by_id(activity_id) do
+      {:error, :poll_activity_not_found}
+    end
+  end
+
+  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}
+end
diff --git a/priv/repo/migrations/20210717000000_add_poll_to_notifications_enum.exs b/priv/repo/migrations/20210717000000_add_poll_to_notifications_enum.exs
new file mode 100644 (file)
index 0000000..9abf40b
--- /dev/null
@@ -0,0 +1,49 @@
+defmodule Pleroma.Repo.Migrations.AddPollToNotificationsEnum do
+  use Ecto.Migration
+
+  @disable_ddl_transaction true
+
+  def up do
+    """
+    alter type notification_type add value 'poll'
+    """
+    |> execute()
+  end
+
+  def down do
+    alter table(:notifications) do
+      modify(:type, :string)
+    end
+
+    """
+    delete from notifications where type = 'poll'
+    """
+    |> execute()
+
+    """
+    drop type if exists notification_type
+    """
+    |> execute()
+
+    """
+    create type notification_type as enum (
+      'follow',
+      'follow_request',
+      'mention',
+      'move',
+      'pleroma:emoji_reaction',
+      'pleroma:chat_mention',
+      'reblog',
+      'favourite',
+      'pleroma:report'
+    )
+    """
+    |> execute()
+
+    """
+    alter table notifications
+    alter column type type notification_type using (type::notification_type)
+    """
+    |> execute()
+  end
+end
index 85f895f0fab7fa8973822d3429c51522c59063ce..716af496d8074a6e1dd25098c7e8c03fdfd18cfb 100644 (file)
@@ -129,6 +129,19 @@ defmodule Pleroma.NotificationTest do
     end
   end
 
+  test "create_poll_notifications/1" do
+    [user1, user2, user3, _, _] = insert_list(5, :user)
+    question = insert(:question, user: user1)
+    activity = insert(:question_activity, question: question)
+
+    {:ok, _, _} = CommonAPI.vote(user2, question, [0])
+    {:ok, _, _} = CommonAPI.vote(user3, question, [1])
+
+    {:ok, notifications} = Notification.create_poll_notifications(activity)
+
+    assert [user2.id, user3.id, user1.id] == Enum.map(notifications, & &1.user_id)
+  end
+
   describe "CommonApi.post/2 notification-related functionality" do
     test_with_mock "creates but does NOT send notification to blocker user",
                    Push,
index 13167f50a8405e1598eca733957f07b3f310caa5..d0988619dcade410815840c611ec9e0585f80621 100644 (file)
@@ -157,6 +157,30 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do
     end
   end
 
+  describe "Question objects" do
+    setup do
+      user = insert(:user)
+      question = build(:question, user: user)
+      question_activity = build(:question_activity, question: question)
+      activity_data = Map.put(question_activity.data, "object", question.data["id"])
+      meta = [object_data: question.data, local: false]
+
+      {:ok, activity, meta} = ActivityPub.persist(activity_data, meta)
+
+      %{activity: activity, meta: meta}
+    end
+
+    test "enqueues the poll end", %{activity: activity, meta: meta} do
+      {:ok, activity, meta} = SideEffects.handle(activity, meta)
+
+      assert_enqueued(
+        worker: Pleroma.Workers.PollWorker,
+        args: %{op: "poll_end", activity_id: activity.id},
+        scheduled_at: NaiveDateTime.from_iso8601!(meta[:object_data]["closed"])
+      )
+    end
+  end
+
   describe "delete users with confirmation pending" do
     setup do
       user = insert(:user, is_confirmed: false)
index a5dfd39342e0776954aff9b44233518d7ff55fd6..4a10a5bc488e651ffbe2b1deb683f5fb9e62a02d 100644 (file)
@@ -18,6 +18,7 @@ defmodule Pleroma.Web.CommonAPITest do
   alias Pleroma.Web.ActivityPub.Visibility
   alias Pleroma.Web.AdminAPI.AccountView
   alias Pleroma.Web.CommonAPI
+  alias Pleroma.Workers.PollWorker
 
   import Pleroma.Factory
   import Mock
@@ -48,6 +49,12 @@ defmodule Pleroma.Web.CommonAPITest do
 
       assert object.data["type"] == "Question"
       assert object.data["oneOf"] |> length() == 2
+
+      assert_enqueued(
+        worker: PollWorker,
+        args: %{op: "poll_end", activity_id: activity.id},
+        scheduled_at: NaiveDateTime.from_iso8601!(object.data["closed"])
+      )
     end
   end
 
index d478a81ee7dfbb3a4bc2a0e580bc23a9f3bf548a..ed66d370ab3fdc59ee5cf49a1b9798d04e39a20e 100644 (file)
@@ -16,6 +16,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
   alias Pleroma.Web.ActivityPub.ActivityPub
   alias Pleroma.Web.ActivityPub.Utils
   alias Pleroma.Web.CommonAPI
+  alias Pleroma.Workers.ScheduledActivityWorker
 
   import Pleroma.Factory
 
@@ -705,11 +706,11 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
         |> json_response_and_validate_schema(200)
 
       assert {:ok, %{id: activity_id}} =
-               perform_job(Pleroma.Workers.ScheduledActivityWorker, %{
+               perform_job(ScheduledActivityWorker, %{
                  activity_id: scheduled_id
                })
 
-      assert Repo.all(Oban.Job) == []
+      refute_enqueued(worker: ScheduledActivityWorker)
 
       object =
         Activity
index 496a688d1f79c6908bb5d8e1ac4dfb9a210c3814..8070c03c950032c64f2b833fd3d14cb638a47937 100644 (file)
@@ -196,6 +196,27 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do
     test_notifications_rendering([notification], user, [expected])
   end
 
+  test "Poll notification" do
+    user = insert(:user)
+    activity = insert(:question_activity, user: user)
+    {:ok, [notification]} = Notification.create_poll_notifications(activity)
+
+    expected = %{
+      id: to_string(notification.id),
+      pleroma: %{is_seen: false, is_muted: false},
+      type: "poll",
+      account:
+        AccountView.render("show.json", %{
+          user: user,
+          for: user
+        }),
+      status: StatusView.render("show.json", %{activity: activity, for: user}),
+      created_at: Utils.to_masto_date(notification.inserted_at)
+    }
+
+    test_notifications_rendering([notification], user, [expected])
+  end
+
   test "Report notification" do
     reporting_user = insert(:user)
     reported_user = insert(:user)
index f31f64a503e2996ee5d9fc7130eb203dc4955b5c..4a78425ce4d2e3ccb21683a032b330cdda4826ec 100644 (file)
@@ -213,6 +213,38 @@ defmodule Pleroma.Factory do
     }
   end
 
+  def question_factory(attrs \\ %{}) do
+    user = attrs[:user] || insert(:user)
+
+    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(),
+      "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)
 
@@ -428,6 +460,33 @@ defmodule Pleroma.Factory do
     }
   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"]
+    }
+    |> Map.merge(attrs)
+  end
+
   def oauth_app_factory do
     %Pleroma.Web.OAuth.App{
       client_name: sequence(:client_name, &"Some client #{&1}"),