Add ability to follow hashtags (#336)
authorfloatingghost <hannah@coffee-and-dreams.uk>
Sun, 4 Dec 2022 17:36:59 +0000 (17:36 +0000)
committerfloatingghost <hannah@coffee-and-dreams.uk>
Sun, 4 Dec 2022 17:36:59 +0000 (17:36 +0000)
Co-authored-by: FloatingGhost <hannah@coffee-and-dreams.uk>
Reviewed-on: https://akkoma.dev/AkkomaGang/akkoma/pulls/336

18 files changed:
lib/mix/tasks/pleroma/user.ex
lib/pleroma/hashtag.ex
lib/pleroma/user.ex
lib/pleroma/user/hashtag_follow.ex [new file with mode: 0644]
lib/pleroma/web/activity_pub/activity_pub.ex
lib/pleroma/web/api_spec/operations/tag_operation.ex [new file with mode: 0644]
lib/pleroma/web/api_spec/schemas/tag.ex
lib/pleroma/web/mastodon_api/controllers/tag_controller.ex [new file with mode: 0644]
lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex
lib/pleroma/web/mastodon_api/views/tag_view.ex [new file with mode: 0644]
lib/pleroma/web/router.ex
lib/pleroma/web/streamer.ex
priv/repo/migrations/20221203232118_add_user_follows_hashtag.exs [new file with mode: 0644]
test/pleroma/user_test.exs
test/pleroma/web/activity_pub/activity_pub_test.exs
test/pleroma/web/mastodon_api/controllers/tag_controller_test.exs [new file with mode: 0644]
test/pleroma/web/streamer_test.exs
test/support/factory.ex

index 278a01acc27cec721476894c6cce4e4e7423c577..dd1cdca5b72d6804604cab3919f1fcd027bf1a3d 100644 (file)
@@ -471,9 +471,15 @@ defmodule Mix.Tasks.Pleroma.User do
 
   def run(["timeline_query", nickname]) do
     start_pleroma()
+
     params = %{local: true}
 
     with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do
+      followed_hashtags =
+        user
+        |> User.followed_hashtags()
+        |> Enum.map(& &1.id)
+
       params =
         params
         |> Map.put(:type, ["Create", "Announce"])
@@ -484,6 +490,7 @@ defmodule Mix.Tasks.Pleroma.User do
         |> Map.put(:announce_filtering_user, user)
         |> Map.put(:user, user)
         |> Map.put(:local_only, params[:local])
+        |> Map.put(:hashtags, followed_hashtags)
         |> Map.delete(:local)
 
       _activities =
index 53e2e9c897d564dd788306a72fa640ff75cbdce8..9030ee4e92c0f1537476459f0eda21eb2b49c756 100644 (file)
@@ -10,6 +10,7 @@ defmodule Pleroma.Hashtag do
 
   alias Ecto.Multi
   alias Pleroma.Hashtag
+  alias Pleroma.User.HashtagFollow
   alias Pleroma.Object
   alias Pleroma.Repo
 
@@ -27,6 +28,14 @@ defmodule Pleroma.Hashtag do
     |> String.trim()
   end
 
+  def get_by_id(id) do
+    Repo.get(Hashtag, id)
+  end
+
+  def get_by_name(name) do
+    Repo.get_by(Hashtag, name: normalize_name(name))
+  end
+
   def get_or_create_by_name(name) do
     changeset = changeset(%Hashtag{}, %{name: name})
 
@@ -103,4 +112,22 @@ defmodule Pleroma.Hashtag do
       {:ok, deleted_count}
     end
   end
+
+  def get_followers(%Hashtag{id: hashtag_id}) do
+    from(hf in HashtagFollow)
+    |> where([hf], hf.hashtag_id == ^hashtag_id)
+    |> join(:inner, [hf], u in assoc(hf, :user))
+    |> select([hf, u], u.id)
+    |> Repo.all()
+  end
+
+  def get_recipients_for_activity(%Pleroma.Activity{object: %{hashtags: tags}})
+      when is_list(tags) do
+    tags
+    |> Enum.map(&get_followers/1)
+    |> List.flatten()
+    |> Enum.uniq()
+  end
+
+  def get_recipients_for_activity(_activity), do: []
 end
index b0ab9d0cd6ada61835b7bad09019524b9edee2ef..c8262b37b6a146942bfa0f7e3f34257fce42c02e 100644 (file)
@@ -18,6 +18,8 @@ defmodule Pleroma.User do
   alias Pleroma.Emoji
   alias Pleroma.FollowingRelationship
   alias Pleroma.Formatter
+  alias Pleroma.Hashtag
+  alias Pleroma.User.HashtagFollow
   alias Pleroma.HTML
   alias Pleroma.Keys
   alias Pleroma.MFA
@@ -168,6 +170,12 @@ defmodule Pleroma.User do
 
     has_many(:frontend_profiles, Pleroma.Akkoma.FrontendSettingsProfile)
 
+    many_to_many(:followed_hashtags, Hashtag,
+      on_replace: :delete,
+      on_delete: :delete_all,
+      join_through: HashtagFollow
+    )
+
     for {relationship_type,
          [
            {outgoing_relation, outgoing_relation_target},
@@ -2550,4 +2558,54 @@ defmodule Pleroma.User do
       _ -> {:error, user}
     end
   end
+
+  defp maybe_load_followed_hashtags(%User{followed_hashtags: follows} = user)
+       when is_list(follows),
+       do: user
+
+  defp maybe_load_followed_hashtags(%User{} = user) do
+    followed_hashtags = HashtagFollow.get_by_user(user)
+    %{user | followed_hashtags: followed_hashtags}
+  end
+
+  def followed_hashtags(%User{followed_hashtags: follows})
+      when is_list(follows),
+      do: follows
+
+  def followed_hashtags(%User{} = user) do
+    {:ok, user} =
+      user
+      |> maybe_load_followed_hashtags()
+      |> set_cache()
+
+    user.followed_hashtags
+  end
+
+  def follow_hashtag(%User{} = user, %Hashtag{} = hashtag) do
+    Logger.debug("Follow hashtag #{hashtag.name} for user #{user.nickname}")
+    user = maybe_load_followed_hashtags(user)
+
+    with {:ok, _} <- HashtagFollow.new(user, hashtag),
+         follows <- HashtagFollow.get_by_user(user),
+         %User{} = user <- user |> Map.put(:followed_hashtags, follows) do
+      user
+      |> set_cache()
+    end
+  end
+
+  def unfollow_hashtag(%User{} = user, %Hashtag{} = hashtag) do
+    Logger.debug("Unfollow hashtag #{hashtag.name} for user #{user.nickname}")
+    user = maybe_load_followed_hashtags(user)
+
+    with {:ok, _} <- HashtagFollow.delete(user, hashtag),
+         follows <- HashtagFollow.get_by_user(user),
+         %User{} = user <- user |> Map.put(:followed_hashtags, follows) do
+      user
+      |> set_cache()
+    end
+  end
+
+  def following_hashtag?(%User{} = user, %Hashtag{} = hashtag) do
+    not is_nil(HashtagFollow.get(user, hashtag))
+  end
 end
diff --git a/lib/pleroma/user/hashtag_follow.ex b/lib/pleroma/user/hashtag_follow.ex
new file mode 100644 (file)
index 0000000..43ed93f
--- /dev/null
@@ -0,0 +1,49 @@
+defmodule Pleroma.User.HashtagFollow do
+  use Ecto.Schema
+  import Ecto.Query
+  import Ecto.Changeset
+
+  alias Pleroma.User
+  alias Pleroma.Hashtag
+  alias Pleroma.Repo
+
+  schema "user_follows_hashtag" do
+    belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
+    belongs_to(:hashtag, Hashtag)
+  end
+
+  def changeset(%__MODULE__{} = user_hashtag_follow, attrs) do
+    user_hashtag_follow
+    |> cast(attrs, [:user_id, :hashtag_id])
+    |> unique_constraint(:hashtag_id,
+      name: :user_hashtag_follows_user_id_hashtag_id_index,
+      message: "already following"
+    )
+    |> validate_required([:user_id, :hashtag_id])
+  end
+
+  def new(%User{} = user, %Hashtag{} = hashtag) do
+    %__MODULE__{}
+    |> changeset(%{user_id: user.id, hashtag_id: hashtag.id})
+    |> Repo.insert(on_conflict: :nothing)
+  end
+
+  def delete(%User{} = user, %Hashtag{} = hashtag) do
+    with %__MODULE__{} = user_hashtag_follow <- get(user, hashtag) do
+      Repo.delete(user_hashtag_follow)
+    else
+      _ -> {:ok, nil}
+    end
+  end
+
+  def get(%User{} = user, %Hashtag{} = hashtag) do
+    from(hf in __MODULE__)
+    |> where([hf], hf.user_id == ^user.id and hf.hashtag_id == ^hashtag.id)
+    |> Repo.one()
+  end
+
+  def get_by_user(%User{} = user) do
+    Ecto.assoc(user, :followed_hashtags)
+    |> Repo.all()
+  end
+end
index a4f1c7041969510c30a2a2cecefb9ac6f98132ff..8233bcbf8f36edb05bf6be51ac5079a6241e4df4 100644 (file)
@@ -933,6 +933,29 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
     )
   end
 
+  # Essentially, either look for activities addressed to `recipients`, _OR_ ones
+  # that reference a hashtag that the user follows
+  # Firstly, two fallbacks in case there's no hashtag constraint, or the user doesn't
+  # follow any
+  defp restrict_recipients_or_hashtags(query, recipients, user, nil) do
+    restrict_recipients(query, recipients, user)
+  end
+
+  defp restrict_recipients_or_hashtags(query, recipients, user, []) do
+    restrict_recipients(query, recipients, user)
+  end
+
+  defp restrict_recipients_or_hashtags(query, recipients, _user, hashtag_ids) do
+    from(
+      [activity, object] in query,
+      join: hto in "hashtags_objects",
+      on: hto.object_id == object.id,
+      where:
+        (hto.hashtag_id in ^hashtag_ids and ^Constants.as_public() in activity.recipients) or
+          fragment("? && ?", ^recipients, activity.recipients)
+    )
+  end
+
   defp restrict_local(query, %{local_only: true}) do
     from(activity in query, where: activity.local == true)
   end
@@ -1380,7 +1403,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
       |> maybe_preload_report_notes(opts)
       |> maybe_set_thread_muted_field(opts)
       |> maybe_order(opts)
-      |> restrict_recipients(recipients, opts[:user])
+      |> restrict_recipients_or_hashtags(recipients, opts[:user], opts[:followed_hashtags])
       |> restrict_replies(opts)
       |> restrict_since(opts)
       |> restrict_local(opts)
diff --git a/lib/pleroma/web/api_spec/operations/tag_operation.ex b/lib/pleroma/web/api_spec/operations/tag_operation.ex
new file mode 100644 (file)
index 0000000..e224571
--- /dev/null
@@ -0,0 +1,65 @@
+defmodule Pleroma.Web.ApiSpec.TagOperation do
+  alias OpenApiSpex.Operation
+  alias OpenApiSpex.Schema
+  alias Pleroma.Web.ApiSpec.Schemas.ApiError
+  alias Pleroma.Web.ApiSpec.Schemas.Tag
+
+  def open_api_operation(action) do
+    operation = String.to_existing_atom("#{action}_operation")
+    apply(__MODULE__, operation, [])
+  end
+
+  def show_operation do
+    %Operation{
+      tags: ["Tags"],
+      summary: "Hashtag",
+      description: "View a hashtag",
+      security: [%{"oAuth" => ["read"]}],
+      parameters: [id_param()],
+      operationId: "TagController.show",
+      responses: %{
+        200 => Operation.response("Hashtag", "application/json", Tag),
+        404 => Operation.response("Not Found", "application/json", ApiError)
+      }
+    }
+  end
+
+  def follow_operation do
+    %Operation{
+      tags: ["Tags"],
+      summary: "Follow a hashtag",
+      description: "Follow a hashtag",
+      security: [%{"oAuth" => ["write:follows"]}],
+      parameters: [id_param()],
+      operationId: "TagController.follow",
+      responses: %{
+        200 => Operation.response("Hashtag", "application/json", Tag),
+        404 => Operation.response("Not Found", "application/json", ApiError)
+      }
+    }
+  end
+
+  def unfollow_operation do
+    %Operation{
+      tags: ["Tags"],
+      summary: "Unfollow a hashtag",
+      description: "Unfollow a hashtag",
+      security: [%{"oAuth" => ["write:follow"]}],
+      parameters: [id_param()],
+      operationId: "TagController.unfollow",
+      responses: %{
+        200 => Operation.response("Hashtag", "application/json", Tag),
+        404 => Operation.response("Not Found", "application/json", ApiError)
+      }
+    }
+  end
+
+  defp id_param do
+    Operation.parameter(
+      :id,
+      :path,
+      %Schema{type: :string},
+      "Name of the hashtag"
+    )
+  end
+end
index 657b675e5ad40b773a9536652d45e5039aeae20a..41b5e5c785277cdfb971f796037f4a608874c0e9 100644 (file)
@@ -17,11 +17,16 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Tag do
         type: :string,
         format: :uri,
         description: "A link to the hashtag on the instance"
+      },
+      following: %Schema{
+        type: :boolean,
+        description: "Whether the authenticated user is following the hashtag"
       }
     },
     example: %{
       name: "cofe",
-      url: "https://lain.com/tag/cofe"
+      url: "https://lain.com/tag/cofe",
+      following: false
     }
   })
 end
diff --git a/lib/pleroma/web/mastodon_api/controllers/tag_controller.ex b/lib/pleroma/web/mastodon_api/controllers/tag_controller.ex
new file mode 100644 (file)
index 0000000..b8995eb
--- /dev/null
@@ -0,0 +1,47 @@
+defmodule Pleroma.Web.MastodonAPI.TagController do
+  @moduledoc "Hashtag routes for mastodon API"
+  use Pleroma.Web, :controller
+
+  alias Pleroma.User
+  alias Pleroma.Hashtag
+
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
+  plug(Pleroma.Web.Plugs.OAuthScopesPlug, %{scopes: ["read"]} when action in [:show])
+
+  plug(
+    Pleroma.Web.Plugs.OAuthScopesPlug,
+    %{scopes: ["write:follows"]} when action in [:follow, :unfollow]
+  )
+
+  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.TagOperation
+
+  def show(conn, %{id: id}) do
+    with %Hashtag{} = hashtag <- Hashtag.get_by_name(id) do
+      render(conn, "show.json", tag: hashtag, for_user: conn.assigns.user)
+    else
+      _ -> conn |> render_error(:not_found, "Hashtag not found")
+    end
+  end
+
+  def follow(conn, %{id: id}) do
+    with %Hashtag{} = hashtag <- Hashtag.get_by_name(id),
+         %User{} = user <- conn.assigns.user,
+         {:ok, _} <-
+           User.follow_hashtag(user, hashtag) do
+      render(conn, "show.json", tag: hashtag, for_user: user)
+    else
+      _ -> render_error(conn, :not_found, "Hashtag not found")
+    end
+  end
+
+  def unfollow(conn, %{id: id}) do
+    with %Hashtag{} = hashtag <- Hashtag.get_by_name(id),
+         %User{} = user <- conn.assigns.user,
+         {:ok, _} <-
+           User.unfollow_hashtag(user, hashtag) do
+      render(conn, "show.json", tag: hashtag, for_user: user)
+    else
+      _ -> render_error(conn, :not_found, "Hashtag not found")
+    end
+  end
+end
index 5f8acb2df3cd7e4b06b4401f6d46354b7211240c..2d0e36420f7a90c6007acd255e73fb467ddedda8 100644 (file)
@@ -41,6 +41,11 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do
 
   # GET /api/v1/timelines/home
   def home(%{assigns: %{user: user}} = conn, params) do
+    followed_hashtags =
+      user
+      |> User.followed_hashtags()
+      |> Enum.map(& &1.id)
+
     params =
       params
       |> Map.put(:type, ["Create", "Announce"])
@@ -50,6 +55,7 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do
       |> Map.put(:announce_filtering_user, user)
       |> Map.put(:user, user)
       |> Map.put(:local_only, params[:local])
+      |> Map.put(:followed_hashtags, followed_hashtags)
       |> Map.delete(:local)
 
     activities =
diff --git a/lib/pleroma/web/mastodon_api/views/tag_view.ex b/lib/pleroma/web/mastodon_api/views/tag_view.ex
new file mode 100644 (file)
index 0000000..6e491c2
--- /dev/null
@@ -0,0 +1,21 @@
+defmodule Pleroma.Web.MastodonAPI.TagView do
+  use Pleroma.Web, :view
+  alias Pleroma.User
+  alias Pleroma.Web.Router.Helpers
+
+  def render("show.json", %{tag: tag, for_user: user}) do
+    following =
+      with %User{} <- user do
+        User.following_hashtag?(user, tag)
+      else
+        _ -> false
+      end
+
+    %{
+      name: tag.name,
+      url: Helpers.tag_feed_url(Pleroma.Web.Endpoint, :feed, tag.name),
+      history: [],
+      following: following
+    }
+  end
+end
index 71a9e4d29febdcac4223d043bd874eaa1c6e6066..a34dd26ceb94d9f1a83023977491f29ec1d55c7f 100644 (file)
@@ -598,6 +598,10 @@ defmodule Pleroma.Web.Router do
 
     get("/announcements", AnnouncementController, :index)
     post("/announcements/:id/dismiss", AnnouncementController, :mark_read)
+
+    get("/tags/:id", TagController, :show)
+    post("/tags/:id/follow", TagController, :follow)
+    post("/tags/:id/unfollow", TagController, :unfollow)
   end
 
   scope "/api/web", Pleroma.Web do
index c03e7fc30a569844cf7032ce4a93e0e8bc7a4903..f009fbd9e1853e01001209207e6cd83270ca1a89 100644 (file)
@@ -17,6 +17,7 @@ defmodule Pleroma.Web.Streamer do
   alias Pleroma.Web.OAuth.Token
   alias Pleroma.Web.Plugs.OAuthScopesPlug
   alias Pleroma.Web.StreamerView
+  require Pleroma.Constants
 
   @mix_env Mix.env()
   @registry Pleroma.Web.StreamerRegistry
@@ -252,7 +253,17 @@ defmodule Pleroma.Web.Streamer do
       User.get_recipients_from_activity(item)
       |> Enum.map(fn %{id: id} -> "user:#{id}" end)
 
-    Enum.each(recipient_topics, fn topic ->
+    hashtag_recipients =
+      if Pleroma.Constants.as_public() in item.recipients do
+        Pleroma.Hashtag.get_recipients_for_activity(item)
+        |> Enum.map(fn id -> "user:#{id}" end)
+      else
+        []
+      end
+
+    all_recipients = Enum.uniq(recipient_topics ++ hashtag_recipients)
+
+    Enum.each(all_recipients, fn topic ->
       push_to_socket(topic, item)
     end)
   end
diff --git a/priv/repo/migrations/20221203232118_add_user_follows_hashtag.exs b/priv/repo/migrations/20221203232118_add_user_follows_hashtag.exs
new file mode 100644 (file)
index 0000000..27fff25
--- /dev/null
@@ -0,0 +1,12 @@
+defmodule Pleroma.Repo.Migrations.AddUserFollowsHashtag do
+  use Ecto.Migration
+
+  def change do
+    create table(:user_follows_hashtag) do
+      add(:hashtag_id, references(:hashtags))
+      add(:user_id, references(:users, type: :uuid, on_delete: :delete_all))
+    end
+
+    create(unique_index(:user_follows_hashtag, [:user_id, :hashtag_id]))
+  end
+end
index 44763daf70bcf544c273dea4866693b3444b6495..cc6634aba37197b8979a7a1ca7618ece7e13cd6b 100644 (file)
@@ -2679,4 +2679,74 @@ defmodule Pleroma.UserTest do
       assert user.ap_id in user3_updated.also_known_as
     end
   end
+
+  describe "follow_hashtag/2" do
+    test "should follow a hashtag" do
+      user = insert(:user)
+      hashtag = insert(:hashtag)
+
+      assert {:ok, _} = user |> User.follow_hashtag(hashtag)
+
+      user = User.get_cached_by_ap_id(user.ap_id)
+
+      assert user.followed_hashtags |> Enum.count() == 1
+      assert hashtag.name in Enum.map(user.followed_hashtags, fn %{name: name} -> name end)
+    end
+
+    test "should not follow a hashtag twice" do
+      user = insert(:user)
+      hashtag = insert(:hashtag)
+
+      assert {:ok, _} = user |> User.follow_hashtag(hashtag)
+
+      assert {:ok, _} = user |> User.follow_hashtag(hashtag)
+
+      user = User.get_cached_by_ap_id(user.ap_id)
+
+      assert user.followed_hashtags |> Enum.count() == 1
+      assert hashtag.name in Enum.map(user.followed_hashtags, fn %{name: name} -> name end)
+    end
+
+    test "can follow multiple hashtags" do
+      user = insert(:user)
+      hashtag = insert(:hashtag)
+      other_hashtag = insert(:hashtag)
+
+      assert {:ok, _} = user |> User.follow_hashtag(hashtag)
+      assert {:ok, _} = user |> User.follow_hashtag(other_hashtag)
+
+      user = User.get_cached_by_ap_id(user.ap_id)
+
+      assert user.followed_hashtags |> Enum.count() == 2
+      assert hashtag.name in Enum.map(user.followed_hashtags, fn %{name: name} -> name end)
+      assert other_hashtag.name in Enum.map(user.followed_hashtags, fn %{name: name} -> name end)
+    end
+  end
+
+  describe "unfollow_hashtag/2" do
+    test "should unfollow a hashtag" do
+      user = insert(:user)
+      hashtag = insert(:hashtag)
+
+      assert {:ok, _} = user |> User.follow_hashtag(hashtag)
+      assert {:ok, _} = user |> User.unfollow_hashtag(hashtag)
+
+      user = User.get_cached_by_ap_id(user.ap_id)
+
+      assert user.followed_hashtags |> Enum.count() == 0
+    end
+
+    test "should not error when trying to unfollow a hashtag twice" do
+      user = insert(:user)
+      hashtag = insert(:hashtag)
+
+      assert {:ok, _} = user |> User.follow_hashtag(hashtag)
+      assert {:ok, _} = user |> User.unfollow_hashtag(hashtag)
+      assert {:ok, _} = user |> User.unfollow_hashtag(hashtag)
+
+      user = User.get_cached_by_ap_id(user.ap_id)
+
+      assert user.followed_hashtags |> Enum.count() == 0
+    end
+  end
 end
index 8d39b1076918fb6830b44684c29609d86af1eb65..fc452ef1a69776d6738861fab8c38114b0afbd2c 100644 (file)
@@ -719,6 +719,24 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
     end
   end
 
+  describe "fetch activities for followed hashtags" do
+    test "it should return public activities that reference a given hashtag" do
+      hashtag = insert(:hashtag, name: "tenshi")
+      user = insert(:user)
+
+      {:ok, public} = CommonAPI.post(user, %{status: "maji #tenshi", visibility: "public"})
+      {:ok, _unrelated} = CommonAPI.post(user, %{status: "dai #tensh", visibility: "public"})
+      {:ok, unlisted} = CommonAPI.post(user, %{status: "maji #tenshi", visibility: "unlisted"})
+      {:ok, _private} = CommonAPI.post(user, %{status: "maji #tenshi", visibility: "private"})
+
+      activities = ActivityPub.fetch_activities([], %{followed_hashtags: [hashtag.id]})
+      assert length(activities) == 2
+      public_id = public.id
+      unlisted_id = unlisted.id
+      assert [%{id: ^public_id}, %{id: ^unlisted_id}] = activities
+    end
+  end
+
   describe "fetch activities in context" do
     test "retrieves activities that have a given context" do
       {:ok, activity} = ActivityBuilder.insert(%{"type" => "Create", "context" => "2hu"})
diff --git a/test/pleroma/web/mastodon_api/controllers/tag_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/tag_controller_test.exs
new file mode 100644 (file)
index 0000000..a1b73ad
--- /dev/null
@@ -0,0 +1,97 @@
+defmodule Pleroma.Web.MastodonAPI.TagControllerTest do
+  use Pleroma.Web.ConnCase
+
+  import Pleroma.Factory
+  import Tesla.Mock
+
+  alias Pleroma.User
+
+  setup do
+    mock(fn env -> apply(HttpRequestMock, :request, [env]) end)
+    :ok
+  end
+
+  describe "GET /api/v1/tags/:id" do
+    test "returns 200 with tag" do
+      %{user: user, conn: conn} = oauth_access(["read"])
+
+      tag = insert(:hashtag, name: "jubjub")
+      {:ok, _user} = User.follow_hashtag(user, tag)
+
+      response =
+        conn
+        |> get("/api/v1/tags/jubjub")
+        |> json_response_and_validate_schema(200)
+
+      assert %{
+               "name" => "jubjub",
+               "url" => "http://localhost:4001/tags/jubjub",
+               "history" => [],
+               "following" => true
+             } = response
+    end
+
+    test "returns 404 with unknown tag" do
+      %{conn: conn} = oauth_access(["read"])
+
+      conn
+      |> get("/api/v1/tags/jubjub")
+      |> json_response_and_validate_schema(404)
+    end
+  end
+
+  describe "POST /api/v1/tags/:id/follow" do
+    test "should follow a hashtag" do
+      %{user: user, conn: conn} = oauth_access(["write:follows"])
+      hashtag = insert(:hashtag, name: "jubjub")
+
+      response =
+        conn
+        |> post("/api/v1/tags/jubjub/follow")
+        |> json_response_and_validate_schema(200)
+
+      assert response["following"] == true
+      user = User.get_cached_by_ap_id(user.ap_id)
+      assert User.following_hashtag?(user, hashtag)
+    end
+
+    test "should 404 if hashtag doesn't exist" do
+      %{conn: conn} = oauth_access(["write:follows"])
+
+      response =
+        conn
+        |> post("/api/v1/tags/rubrub/follow")
+        |> json_response_and_validate_schema(404)
+
+      assert response["error"] == "Hashtag not found"
+    end
+  end
+
+  describe "POST /api/v1/tags/:id/unfollow" do
+    test "should unfollow a hashtag" do
+      %{user: user, conn: conn} = oauth_access(["write:follows"])
+      hashtag = insert(:hashtag, name: "jubjub")
+      {:ok, user} = User.follow_hashtag(user, hashtag)
+
+      response =
+        conn
+        |> post("/api/v1/tags/jubjub/unfollow")
+        |> json_response_and_validate_schema(200)
+
+      assert response["following"] == false
+      user = User.get_cached_by_ap_id(user.ap_id)
+      refute User.following_hashtag?(user, hashtag)
+    end
+
+    test "should 404 if hashtag doesn't exist" do
+      %{conn: conn} = oauth_access(["write:follows"])
+
+      response =
+        conn
+        |> post("/api/v1/tags/rubrub/unfollow")
+        |> json_response_and_validate_schema(404)
+
+      assert response["error"] == "Hashtag not found"
+    end
+  end
+end
index a9db5a0158162e6f196480147dfc2c9dc55c69de..b07c16faa1aba84525e6d1daf690e77ca7cbde2f 100644 (file)
@@ -410,6 +410,36 @@ defmodule Pleroma.Web.StreamerTest do
       assert_receive {:render_with_user, _, "status_update.json", ^create, ^stream}
       refute Streamer.filtered_by_user?(user, edited)
     end
+
+    test "it streams posts containing followed hashtags on the 'user' stream", %{
+      user: user,
+      token: oauth_token
+    } do
+      hashtag = insert(:hashtag, %{name: "tenshi"})
+      other_user = insert(:user)
+      {:ok, user} = User.follow_hashtag(user, hashtag)
+
+      Streamer.get_topic_and_add_socket("user", user, oauth_token)
+      {:ok, activity} = CommonAPI.post(other_user, %{status: "hey #tenshi"})
+
+      assert_receive {:render_with_user, _, "update.json", ^activity, _}
+    end
+
+    test "should not stream private posts containing followed hashtags on the 'user' stream", %{
+      user: user,
+      token: oauth_token
+    } do
+      hashtag = insert(:hashtag, %{name: "tenshi"})
+      other_user = insert(:user)
+      {:ok, user} = User.follow_hashtag(user, hashtag)
+
+      Streamer.get_topic_and_add_socket("user", user, oauth_token)
+
+      {:ok, activity} =
+        CommonAPI.post(other_user, %{status: "hey #tenshi", visibility: "private"})
+
+      refute_receive {:render_with_user, _, "update.json", ^activity, _}
+    end
   end
 
   describe "public streams" do
index 6ce4decbcc144212797e8569c90b2b0c152f67d7..808f8f8879e9148bca2dd0e63e6dd5f0c8fed596 100644 (file)
@@ -716,4 +716,11 @@ defmodule Pleroma.Factory do
       user: user
     }
   end
+
+  def hashtag_factory(params \\ %{}) do
+    %Pleroma.Hashtag{
+      name: "test #{sequence(:hashtag_name, & &1)}"
+    }
+    |> Map.merge(params)
+  end
 end