support for expires_in/expires_at in filters
authorAlexander Strizhakov <alex.strizhakov@gmail.com>
Mon, 25 Jan 2021 12:34:59 +0000 (15:34 +0300)
committerAlexander Strizhakov <alex.strizhakov@gmail.com>
Tue, 26 Jan 2021 05:27:45 +0000 (08:27 +0300)
CHANGELOG.md
benchmarks/load_testing/fetcher.ex
config/config.exs
lib/pleroma/filter.ex
lib/pleroma/web/api_spec/operations/filter_operation.ex
lib/pleroma/web/mastodon_api/controllers/filter_controller.ex
lib/pleroma/workers/purge_expired_filter.ex [new file with mode: 0644]
test/pleroma/filter_test.exs
test/pleroma/web/mastodon_api/controllers/filter_controller_test.exs
test/pleroma/workers/purge_expired_filter_test.exs [new file with mode: 0644]
test/support/factory.ex

index 9ea242e11ee8279f6fdcabaafc07e26c5c0dc6ae..226fa2bbfcac4c8eaac6b1f422f87da0ce7a6dec 100644 (file)
@@ -64,6 +64,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
   - Mastodon API: Current user is now included in conversation if it's the only participant.
   - Mastodon API: Fixed last_status.account being not filled with account data.
   - Mastodon API: Fixed own_votes being not returned with poll data.
+  - Mastodon API: Support for expires_in/expires_at in the Filters.
 </details>
 
 ## Unreleased (Patch)
index dfbd916bef3757ef988803a2e20fe82139705e4b..607b7d4cb662c01718115f97f5767a13f870cf20 100644 (file)
@@ -33,10 +33,11 @@ defmodule Pleroma.LoadTesting.Fetcher do
   end
 
   defp create_filter(user) do
-    Pleroma.Filter.create(%Pleroma.Filter{
+    Pleroma.Filter.create(%{
       user_id: user.id,
       phrase: "must be filtered",
-      hide: true
+      hide: true,
+      context: ["home"]
     })
   end
 
index 5eca250bb0401043286cefdcbef352fa6609586d..715524e84507d82a8f29b0e2e7bba757dd1941a2 100644 (file)
@@ -543,6 +543,7 @@ config :pleroma, Oban,
   queues: [
     activity_expiration: 10,
     token_expiration: 5,
+    filter_expiration: 1,
     backup: 1,
     federator_incoming: 50,
     federator_outgoing: 50,
index fc531f7fcdd7eef6e5821e066143bde394303424..82b9caf9b5c8e4589ed97d395f15fda29ec3d636 100644 (file)
@@ -11,6 +11,9 @@ defmodule Pleroma.Filter do
   alias Pleroma.Repo
   alias Pleroma.User
 
+  @type t() :: %__MODULE__{}
+  @type format() :: :postgres | :re
+
   schema "filters" do
     belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
     field(:filter_id, :integer)
@@ -18,15 +21,16 @@ defmodule Pleroma.Filter do
     field(:whole_word, :boolean, default: true)
     field(:phrase, :string)
     field(:context, {:array, :string})
-    field(:expires_at, :utc_datetime)
+    field(:expires_at, :naive_datetime)
 
     timestamps()
   end
 
+  @spec get(integer() | String.t(), User.t()) :: t() | nil
   def get(id, %{id: user_id} = _user) do
     query =
       from(
-        f in Pleroma.Filter,
+        f in __MODULE__,
         where: f.filter_id == ^id,
         where: f.user_id == ^user_id
       )
@@ -34,14 +38,17 @@ defmodule Pleroma.Filter do
     Repo.one(query)
   end
 
+  @spec get_active(Ecto.Query.t() | module()) :: Ecto.Query.t()
   def get_active(query) do
     from(f in query, where: is_nil(f.expires_at) or f.expires_at > ^NaiveDateTime.utc_now())
   end
 
+  @spec get_irreversible(Ecto.Query.t()) :: Ecto.Query.t()
   def get_irreversible(query) do
     from(f in query, where: f.hide)
   end
 
+  @spec get_filters(Ecto.Query.t() | module(), User.t()) :: [t()]
   def get_filters(query \\ __MODULE__, %User{id: user_id}) do
     query =
       from(
@@ -53,7 +60,32 @@ defmodule Pleroma.Filter do
     Repo.all(query)
   end
 
-  def create(%Pleroma.Filter{user_id: user_id, filter_id: nil} = filter) do
+  @spec create(map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
+  def create(attrs \\ %{}) do
+    Repo.transaction(fn -> create_with_expiration(attrs) end)
+  end
+
+  defp create_with_expiration(attrs) do
+    with {:ok, filter} <- do_create(attrs),
+         {:ok, _} <- maybe_add_expiration_job(filter) do
+      filter
+    else
+      {:error, error} -> Repo.rollback(error)
+    end
+  end
+
+  defp do_create(attrs) do
+    %__MODULE__{}
+    |> cast(attrs, [:phrase, :context, :hide, :expires_at, :whole_word, :user_id, :filter_id])
+    |> maybe_add_filter_id()
+    |> validate_required([:phrase, :context, :user_id, :filter_id])
+    |> maybe_add_expires_at(attrs)
+    |> Repo.insert()
+  end
+
+  defp maybe_add_filter_id(%{changes: %{filter_id: _}} = changeset), do: changeset
+
+  defp maybe_add_filter_id(%{changes: %{user_id: user_id}} = changeset) do
     # If filter_id wasn't given, use the max filter_id for this user plus 1.
     # XXX This could result in a race condition if a user tries to add two
     # different filters for their account from two different clients at the
@@ -61,7 +93,7 @@ defmodule Pleroma.Filter do
 
     max_id_query =
       from(
-        f in Pleroma.Filter,
+        f in __MODULE__,
         where: f.user_id == ^user_id,
         select: max(f.filter_id)
       )
@@ -76,34 +108,92 @@ defmodule Pleroma.Filter do
           max_id + 1
       end
 
-    filter
-    |> Map.put(:filter_id, filter_id)
-    |> Repo.insert()
+    change(changeset, filter_id: filter_id)
+  end
+
+  # don't override expires_at, if passed expires_at and expires_in
+  defp maybe_add_expires_at(%{changes: %{expires_at: %NaiveDateTime{} = _}} = changeset, _) do
+    changeset
   end
 
-  def create(%Pleroma.Filter{} = filter) do
-    Repo.insert(filter)
+  defp maybe_add_expires_at(changeset, %{expires_in: expires_in})
+       when is_integer(expires_in) and expires_in > 0 do
+    expires_at =
+      NaiveDateTime.utc_now()
+      |> NaiveDateTime.add(expires_in)
+      |> NaiveDateTime.truncate(:second)
+
+    change(changeset, expires_at: expires_at)
   end
 
-  def delete(%Pleroma.Filter{id: filter_key} = filter) when is_number(filter_key) do
-    Repo.delete(filter)
+  defp maybe_add_expires_at(changeset, %{expires_in: nil}) do
+    change(changeset, expires_at: nil)
   end
 
-  def delete(%Pleroma.Filter{id: filter_key} = filter) when is_nil(filter_key) do
-    %Pleroma.Filter{id: id} = get(filter.filter_id, %{id: filter.user_id})
+  defp maybe_add_expires_at(changeset, _), do: changeset
 
-    filter
-    |> Map.put(:id, id)
-    |> Repo.delete()
+  defp maybe_add_expiration_job(%{expires_at: %NaiveDateTime{} = expires_at} = filter) do
+    Pleroma.Workers.PurgeExpiredFilter.enqueue(%{
+      filter_id: filter.id,
+      expires_at: DateTime.from_naive!(expires_at, "Etc/UTC")
+    })
   end
 
-  def update(%Pleroma.Filter{} = filter, params) do
+  defp maybe_add_expiration_job(_), do: {:ok, nil}
+
+  @spec delete(t()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
+  def delete(%__MODULE__{} = filter) do
+    Repo.transaction(fn -> delete_with_expiration(filter) end)
+  end
+
+  defp delete_with_expiration(filter) do
+    with {:ok, _} <- maybe_delete_old_expiration_job(filter, nil),
+         {:ok, filter} <- Repo.delete(filter) do
+      filter
+    else
+      {:error, error} -> Repo.rollback(error)
+    end
+  end
+
+  @spec update(t(), map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
+  def update(%__MODULE__{} = filter, params) do
+    Repo.transaction(fn -> update_with_expiration(filter, params) end)
+  end
+
+  defp update_with_expiration(filter, params) do
+    with {:ok, updated} <- do_update(filter, params),
+         {:ok, _} <- maybe_delete_old_expiration_job(filter, updated),
+         {:ok, _} <-
+           maybe_add_expiration_job(updated) do
+      updated
+    else
+      {:error, error} -> Repo.rollback(error)
+    end
+  end
+
+  defp do_update(filter, params) do
     filter
     |> cast(params, [:phrase, :context, :hide, :expires_at, :whole_word])
     |> validate_required([:phrase, :context])
+    |> maybe_add_expires_at(params)
     |> Repo.update()
   end
 
+  defp maybe_delete_old_expiration_job(%{expires_at: nil}, _), do: {:ok, nil}
+
+  defp maybe_delete_old_expiration_job(%{expires_at: expires_at}, %{expires_at: expires_at}) do
+    {:ok, nil}
+  end
+
+  defp maybe_delete_old_expiration_job(%{id: id}, _) do
+    with %Oban.Job{} = job <- Pleroma.Workers.PurgeExpiredFilter.get_expiration(id) do
+      Repo.delete(job)
+    else
+      nil -> {:ok, nil}
+    end
+  end
+
+  @spec compose_regex(User.t() | [t()], format()) :: String.t() | Regex.t() | nil
   def compose_regex(user_or_filters, format \\ :postgres)
 
   def compose_regex(%User{} = user, format) do
index c5b0c035b72bae0de2e8b51ab323dab921861328..9374a78685c18ba1a1994a172d9fa3abbace8a33 100644 (file)
@@ -6,6 +6,7 @@ defmodule Pleroma.Web.ApiSpec.FilterOperation do
   alias OpenApiSpex.Operation
   alias OpenApiSpex.Schema
   alias Pleroma.Web.ApiSpec.Helpers
+  alias Pleroma.Web.ApiSpec.Schemas.ApiError
   alias Pleroma.Web.ApiSpec.Schemas.BooleanLike
 
   def open_api_operation(action) do
@@ -20,7 +21,8 @@ defmodule Pleroma.Web.ApiSpec.FilterOperation do
       operationId: "FilterController.index",
       security: [%{"oAuth" => ["read:filters"]}],
       responses: %{
-        200 => Operation.response("Filters", "application/json", array_of_filters())
+        200 => Operation.response("Filters", "application/json", array_of_filters()),
+        403 => Operation.response("Error", "application/json", ApiError)
       }
     }
   end
@@ -32,7 +34,10 @@ defmodule Pleroma.Web.ApiSpec.FilterOperation do
       operationId: "FilterController.create",
       requestBody: Helpers.request_body("Parameters", create_request(), required: true),
       security: [%{"oAuth" => ["write:filters"]}],
-      responses: %{200 => Operation.response("Filter", "application/json", filter())}
+      responses: %{
+        200 => Operation.response("Filter", "application/json", filter()),
+        403 => Operation.response("Error", "application/json", ApiError)
+      }
     }
   end
 
@@ -44,7 +49,9 @@ defmodule Pleroma.Web.ApiSpec.FilterOperation do
       operationId: "FilterController.show",
       security: [%{"oAuth" => ["read:filters"]}],
       responses: %{
-        200 => Operation.response("Filter", "application/json", filter())
+        200 => Operation.response("Filter", "application/json", filter()),
+        403 => Operation.response("Error", "application/json", ApiError),
+        404 => Operation.response("Error", "application/json", ApiError)
       }
     }
   end
@@ -58,7 +65,8 @@ defmodule Pleroma.Web.ApiSpec.FilterOperation do
       requestBody: Helpers.request_body("Parameters", update_request(), required: true),
       security: [%{"oAuth" => ["write:filters"]}],
       responses: %{
-        200 => Operation.response("Filter", "application/json", filter())
+        200 => Operation.response("Filter", "application/json", filter()),
+        403 => Operation.response("Error", "application/json", ApiError)
       }
     }
   end
@@ -75,7 +83,8 @@ defmodule Pleroma.Web.ApiSpec.FilterOperation do
           Operation.response("Filter", "application/json", %Schema{
             type: :object,
             description: "Empty object"
-          })
+          }),
+        403 => Operation.response("Error", "application/json", ApiError)
       }
     }
   end
@@ -210,15 +219,13 @@ defmodule Pleroma.Web.ApiSpec.FilterOperation do
           nullable: true,
           description: "Consider word boundaries?",
           default: true
+        },
+        expires_in: %Schema{
+          nullable: true,
+          type: :integer,
+          description:
+            "Number of seconds from now the filter should expire. Otherwise, null for a filter that doesn't expire."
         }
-        # TODO: probably should implement filter expiration
-        # expires_in: %Schema{
-        #   type: :string,
-        #   format: :"date-time",
-        #   description:
-        #     "ISO 8601 Datetime for when the filter expires. Otherwise,
-        #  null for a filter that doesn't expire."
-        # }
       },
       required: [:phrase, :context],
       example: %{
index c8b4a309553c894f27e7dab00d504980d149f850..9b1ae809d33184e1204a969aef027f5c8d574231 100644 (file)
@@ -20,6 +20,8 @@ defmodule Pleroma.Web.MastodonAPI.FilterController do
 
   defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.FilterOperation
 
+  action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
+
   @doc "GET /api/v1/filters"
   def index(%{assigns: %{user: user}} = conn, _) do
     filters = Filter.get_filters(user)
@@ -29,25 +31,23 @@ defmodule Pleroma.Web.MastodonAPI.FilterController do
 
   @doc "POST /api/v1/filters"
   def create(%{assigns: %{user: user}, body_params: params} = conn, _) do
-    query = %Filter{
-      user_id: user.id,
-      phrase: params.phrase,
-      context: params.context,
-      hide: params.irreversible,
-      whole_word: params.whole_word
-      # TODO: support `expires_in` parameter (as in Mastodon API)
-    }
-
-    {:ok, response} = Filter.create(query)
-
-    render(conn, "show.json", filter: response)
+    with {:ok, response} <-
+           params
+           |> Map.put(:user_id, user.id)
+           |> Map.put(:hide, params[:irreversible])
+           |> Map.delete(:irreversible)
+           |> Filter.create() do
+      render(conn, "show.json", filter: response)
+    end
   end
 
   @doc "GET /api/v1/filters/:id"
   def show(%{assigns: %{user: user}} = conn, %{id: filter_id}) do
-    filter = Filter.get(filter_id, user)
-
-    render(conn, "show.json", filter: filter)
+    with %Filter{} = filter <- Filter.get(filter_id, user) do
+      render(conn, "show.json", filter: filter)
+    else
+      nil -> {:error, :not_found}
+    end
   end
 
   @doc "PUT /api/v1/filters/:id"
@@ -56,28 +56,31 @@ defmodule Pleroma.Web.MastodonAPI.FilterController do
         %{id: filter_id}
       ) do
     params =
-      params
-      |> Map.delete(:irreversible)
-      |> Map.put(:hide, params[:irreversible])
-      |> Enum.reject(fn {_key, value} -> is_nil(value) end)
-      |> Map.new()
-
-    # TODO: support `expires_in` parameter (as in Mastodon API)
+      if is_boolean(params[:irreversible]) do
+        params
+        |> Map.put(:hide, params[:irreversible])
+        |> Map.delete(:irreversible)
+      else
+        params
+      end
 
     with %Filter{} = filter <- Filter.get(filter_id, user),
          {:ok, %Filter{} = filter} <- Filter.update(filter, params) do
       render(conn, "show.json", filter: filter)
+    else
+      nil -> {:error, :not_found}
+      error -> error
     end
   end
 
   @doc "DELETE /api/v1/filters/:id"
   def delete(%{assigns: %{user: user}} = conn, %{id: filter_id}) do
-    query = %Filter{
-      user_id: user.id,
-      filter_id: filter_id
-    }
-
-    {:ok, _} = Filter.delete(query)
-    json(conn, %{})
+    with %Filter{} = filter <- Filter.get(filter_id, user),
+         {:ok, _} <- Filter.delete(filter) do
+      json(conn, %{})
+    else
+      nil -> {:error, :not_found}
+      error -> error
+    end
   end
 end
diff --git a/lib/pleroma/workers/purge_expired_filter.ex b/lib/pleroma/workers/purge_expired_filter.ex
new file mode 100644 (file)
index 0000000..9c3db8a
--- /dev/null
@@ -0,0 +1,43 @@
+# Pleroma: A lightweight social networking server
+# Copyright Â© 2017-2021 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Workers.PurgeExpiredFilter do
+  @moduledoc """
+  Worker which purges expired filters
+  """
+
+  use Oban.Worker, queue: :filter_expiration, max_attempts: 1, unique: [fields: [:args]]
+
+  import Ecto.Query
+
+  alias Oban.Job
+  alias Pleroma.Repo
+
+  @spec enqueue(%{filter_id: integer(), expires_at: DateTime.t()}) ::
+          {:ok, Job.t()} | {:error, Ecto.Changeset.t()}
+  def enqueue(args) do
+    {scheduled_at, args} = Map.pop(args, :expires_at)
+
+    args
+    |> new(scheduled_at: scheduled_at)
+    |> Oban.insert()
+  end
+
+  @impl true
+  def perform(%Job{args: %{"filter_id" => id}}) do
+    Pleroma.Filter
+    |> Repo.get(id)
+    |> Repo.delete()
+  end
+
+  @spec get_expiration(pos_integer()) :: Job.t() | nil
+  def get_expiration(id) do
+    from(j in Job,
+      where: j.state == "scheduled",
+      where: j.queue == "filter_expiration",
+      where: fragment("?->'filter_id' = ?", j.args, ^id)
+    )
+    |> Repo.one()
+  end
+end
index a9e256e8c7b67cd7e5fd821502df052f740db33e..19ad6b8c0f4c9b984cb603ae4229dc70d137d264 100644 (file)
@@ -7,81 +7,120 @@ defmodule Pleroma.FilterTest do
 
   import Pleroma.Factory
 
+  alias Oban.Job
   alias Pleroma.Filter
-  alias Pleroma.Repo
+
+  setup do
+    [user: insert(:user)]
+  end
 
   describe "creating filters" do
-    test "creating one filter" do
-      user = insert(:user)
+    test "creation validation error", %{user: user} do
+      attrs = %{
+        user_id: user.id,
+        expires_in: 60
+      }
+
+      {:error, _} = Filter.create(attrs)
+
+      assert Repo.all(Job) == []
+    end
 
-      query = %Filter{
+    test "use passed expires_at instead expires_in", %{user: user} do
+      now = NaiveDateTime.utc_now()
+
+      attrs = %{
         user_id: user.id,
-        filter_id: 42,
+        expires_at: now,
         phrase: "knights",
-        context: ["home"]
+        context: ["home"],
+        expires_in: 600
       }
 
-      {:ok, %Filter{} = filter} = Filter.create(query)
+      {:ok, %Filter{} = filter} = Filter.create(attrs)
+
       result = Filter.get(filter.filter_id, user)
-      assert query.phrase == result.phrase
-    end
+      assert result.expires_at == NaiveDateTime.truncate(now, :second)
 
-    test "creating one filter without a pre-defined filter_id" do
-      user = insert(:user)
+      [job] = Repo.all(Job)
 
-      query = %Filter{
+      assert DateTime.truncate(job.scheduled_at, :second) ==
+               now |> NaiveDateTime.truncate(:second) |> DateTime.from_naive!("Etc/UTC")
+    end
+
+    test "creating one filter", %{user: user} do
+      attrs = %{
         user_id: user.id,
+        filter_id: 42,
         phrase: "knights",
         context: ["home"]
       }
 
-      {:ok, %Filter{} = filter} = Filter.create(query)
-      # Should start at 1
-      assert filter.filter_id == 1
+      {:ok, %Filter{} = filter} = Filter.create(attrs)
+      result = Filter.get(filter.filter_id, user)
+      assert attrs.phrase == result.phrase
     end
 
-    test "creating additional filters uses previous highest filter_id + 1" do
-      user = insert(:user)
-
-      query_one = %Filter{
+    test "creating with expired_at", %{user: user} do
+      attrs = %{
         user_id: user.id,
         filter_id: 42,
         phrase: "knights",
+        context: ["home"],
+        expires_in: 60
+      }
+
+      {:ok, %Filter{} = filter} = Filter.create(attrs)
+      result = Filter.get(filter.filter_id, user)
+      assert attrs.phrase == result.phrase
+
+      assert [_] = Repo.all(Job)
+    end
+
+    test "creating one filter without a pre-defined filter_id", %{user: user} do
+      attrs = %{
+        user_id: user.id,
+        phrase: "knights",
         context: ["home"]
       }
 
-      {:ok, %Filter{} = filter_one} = Filter.create(query_one)
+      {:ok, %Filter{} = filter} = Filter.create(attrs)
+      # Should start at 1
+      assert filter.filter_id == 1
+    end
+
+    test "creating additional filters uses previous highest filter_id + 1", %{user: user} do
+      filter1 = insert(:filter, user: user)
 
-      query_two = %Filter{
+      attrs = %{
         user_id: user.id,
         # No filter_id
         phrase: "who",
         context: ["home"]
       }
 
-      {:ok, %Filter{} = filter_two} = Filter.create(query_two)
-      assert filter_two.filter_id == filter_one.filter_id + 1
+      {:ok, %Filter{} = filter2} = Filter.create(attrs)
+      assert filter2.filter_id == filter1.filter_id + 1
     end
 
-    test "filter_id is unique per user" do
-      user_one = insert(:user)
+    test "filter_id is unique per user", %{user: user_one} do
       user_two = insert(:user)
 
-      query_one = %Filter{
+      attrs1 = %{
         user_id: user_one.id,
         phrase: "knights",
         context: ["home"]
       }
 
-      {:ok, %Filter{} = filter_one} = Filter.create(query_one)
+      {:ok, %Filter{} = filter_one} = Filter.create(attrs1)
 
-      query_two = %Filter{
+      attrs2 = %{
         user_id: user_two.id,
         phrase: "who",
         context: ["home"]
       }
 
-      {:ok, %Filter{} = filter_two} = Filter.create(query_two)
+      {:ok, %Filter{} = filter_two} = Filter.create(attrs2)
 
       assert filter_one.filter_id == 1
       assert filter_two.filter_id == 1
@@ -94,65 +133,61 @@ defmodule Pleroma.FilterTest do
     end
   end
 
-  test "deleting a filter" do
-    user = insert(:user)
+  test "deleting a filter", %{user: user} do
+    filter = insert(:filter, user: user)
 
-    query = %Filter{
-      user_id: user.id,
-      filter_id: 0,
-      phrase: "knights",
-      context: ["home"]
-    }
-
-    {:ok, _filter} = Filter.create(query)
-    {:ok, filter} = Filter.delete(query)
-    assert is_nil(Repo.get(Filter, filter.filter_id))
+    assert Repo.get(Filter, filter.id)
+    {:ok, filter} = Filter.delete(filter)
+    refute Repo.get(Filter, filter.id)
   end
 
-  test "getting all filters by an user" do
-    user = insert(:user)
-
-    query_one = %Filter{
+  test "deleting a filter with expires_at is removing Oban job too", %{user: user} do
+    attrs = %{
       user_id: user.id,
-      filter_id: 1,
-      phrase: "knights",
-      context: ["home"]
+      phrase: "cofe",
+      context: ["home"],
+      expires_in: 600
     }
 
-    query_two = %Filter{
-      user_id: user.id,
-      filter_id: 2,
-      phrase: "who",
-      context: ["home"]
-    }
+    {:ok, filter} = Filter.create(attrs)
+    assert %Job{id: job_id} = Pleroma.Workers.PurgeExpiredFilter.get_expiration(filter.id)
+    {:ok, _} = Filter.delete(filter)
 
-    {:ok, filter_one} = Filter.create(query_one)
-    {:ok, filter_two} = Filter.create(query_two)
-    filters = Filter.get_filters(user)
-    assert filter_one in filters
-    assert filter_two in filters
+    assert Repo.get(Job, job_id) == nil
   end
 
-  test "updating a filter" do
-    user = insert(:user)
+  test "getting all filters by an user", %{user: user} do
+    filter1 = insert(:filter, user: user)
+    filter2 = insert(:filter, user: user)
 
-    query_one = %Filter{
-      user_id: user.id,
-      filter_id: 1,
-      phrase: "knights",
-      context: ["home"]
-    }
+    filter_ids = user |> Filter.get_filters() |> collect_ids()
+
+    assert filter1.id in filter_ids
+    assert filter2.id in filter_ids
+  end
+
+  test "updating a filter", %{user: user} do
+    filter = insert(:filter, user: user)
 
     changes = %{
       phrase: "who",
       context: ["home", "timeline"]
     }
 
-    {:ok, filter_one} = Filter.create(query_one)
-    {:ok, filter_two} = Filter.update(filter_one, changes)
+    {:ok, updated_filter} = Filter.update(filter, changes)
+
+    assert filter != updated_filter
+    assert updated_filter.phrase == changes.phrase
+    assert updated_filter.context == changes.context
+  end
+
+  test "updating with error", %{user: user} do
+    filter = insert(:filter, user: user)
+
+    changes = %{
+      phrase: nil
+    }
 
-    assert filter_one != filter_two
-    assert filter_two.phrase == changes.phrase
-    assert filter_two.context == changes.context
+    {:error, _} = Filter.update(filter, changes)
   end
 end
index dc6739178fa92a01ad801c421fd566635ff66667..98ab9e71768d658166ef44b1b84f656a52923437 100644 (file)
 
 defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
   use Pleroma.Web.ConnCase, async: true
+  use Oban.Testing, repo: Pleroma.Repo
 
-  alias Pleroma.Web.MastodonAPI.FilterView
+  import Pleroma.Factory
 
-  test "creating a filter" do
-    %{conn: conn} = oauth_access(["write:filters"])
+  alias Pleroma.Filter
+  alias Pleroma.Repo
+  alias Pleroma.Workers.PurgeExpiredFilter
 
-    filter = %Pleroma.Filter{
-      phrase: "knights",
-      context: ["home"]
-    }
-
-    conn =
+  test "non authenticated creation request", %{conn: conn} do
+    response =
       conn
       |> put_req_header("content-type", "application/json")
-      |> post("/api/v1/filters", %{"phrase" => filter.phrase, context: filter.context})
-
-    assert response = json_response_and_validate_schema(conn, 200)
-    assert response["phrase"] == filter.phrase
-    assert response["context"] == filter.context
-    assert response["irreversible"] == false
-    assert response["id"] != nil
-    assert response["id"] != ""
+      |> post("/api/v1/filters", %{"phrase" => "knights", context: ["home"]})
+      |> json_response(403)
+
+    assert response["error"] == "Invalid credentials."
+  end
+
+  describe "creating" do
+    setup do: oauth_access(["write:filters"])
+
+    test "a common filter", %{conn: conn, user: user} do
+      params = %{
+        phrase: "knights",
+        context: ["home"],
+        irreversible: true
+      }
+
+      response =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> post("/api/v1/filters", params)
+        |> json_response_and_validate_schema(200)
+
+      assert response["phrase"] == params.phrase
+      assert response["context"] == params.context
+      assert response["irreversible"] == true
+      assert response["id"] != nil
+      assert response["id"] != ""
+      assert response["expires_at"] == nil
+
+      filter = Filter.get(response["id"], user)
+      assert filter.hide == true
+    end
+
+    test "a filter with expires_in", %{conn: conn, user: user} do
+      in_seconds = 600
+
+      response =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> post("/api/v1/filters", %{
+          "phrase" => "knights",
+          context: ["home"],
+          expires_in: in_seconds
+        })
+        |> json_response_and_validate_schema(200)
+
+      assert response["irreversible"] == false
+
+      expires_at =
+        NaiveDateTime.utc_now()
+        |> NaiveDateTime.add(in_seconds)
+        |> Pleroma.Web.CommonAPI.Utils.to_masto_date()
+
+      assert response["expires_at"] == expires_at
+
+      filter = Filter.get(response["id"], user)
+
+      id = filter.id
+
+      assert_enqueued(
+        worker: PurgeExpiredFilter,
+        args: %{filter_id: filter.id}
+      )
+
+      assert {:ok, %{id: ^id}} =
+               perform_job(PurgeExpiredFilter, %{
+                 filter_id: filter.id
+               })
+
+      assert Repo.aggregate(Filter, :count, :id) == 0
+    end
   end
 
   test "fetching a list of filters" do
     %{user: user, conn: conn} = oauth_access(["read:filters"])
 
-    query_one = %Pleroma.Filter{
-      user_id: user.id,
-      filter_id: 1,
-      phrase: "knights",
-      context: ["home"]
-    }
+    %{filter_id: id1} = insert(:filter, user: user)
+    %{filter_id: id2} = insert(:filter, user: user)
 
-    query_two = %Pleroma.Filter{
-      user_id: user.id,
-      filter_id: 2,
-      phrase: "who",
-      context: ["home"]
-    }
+    id1 = to_string(id1)
+    id2 = to_string(id2)
 
-    {:ok, filter_one} = Pleroma.Filter.create(query_one)
-    {:ok, filter_two} = Pleroma.Filter.create(query_two)
+    assert [%{"id" => ^id2}, %{"id" => ^id1}] =
+             conn
+             |> get("/api/v1/filters")
+             |> json_response_and_validate_schema(200)
+  end
+
+  test "fetching a list of filters without token", %{conn: conn} do
+    insert(:filter)
 
     response =
       conn
       |> get("/api/v1/filters")
-      |> json_response_and_validate_schema(200)
-
-    assert response ==
-             render_json(
-               FilterView,
-               "index.json",
-               filters: [filter_two, filter_one]
-             )
+      |> json_response(403)
+
+    assert response["error"] == "Invalid credentials."
   end
 
   test "get a filter" do
     %{user: user, conn: conn} = oauth_access(["read:filters"])
 
     # check whole_word false
-    query = %Pleroma.Filter{
-      user_id: user.id,
-      filter_id: 2,
-      phrase: "knight",
-      context: ["home"],
-      whole_word: false
-    }
-
-    {:ok, filter} = Pleroma.Filter.create(query)
+    filter = insert(:filter, user: user, whole_word: false)
 
-    conn = get(conn, "/api/v1/filters/#{filter.filter_id}")
+    resp1 =
+      conn |> get("/api/v1/filters/#{filter.filter_id}") |> json_response_and_validate_schema(200)
 
-    assert response = json_response_and_validate_schema(conn, 200)
-    assert response["whole_word"] == false
+    assert resp1["whole_word"] == false
 
     # check whole_word true
-    %{user: user, conn: conn} = oauth_access(["read:filters"])
-
-    query = %Pleroma.Filter{
-      user_id: user.id,
-      filter_id: 3,
-      phrase: "knight",
-      context: ["home"],
-      whole_word: true
-    }
+    filter = insert(:filter, user: user, whole_word: true)
 
-    {:ok, filter} = Pleroma.Filter.create(query)
+    resp2 =
+      conn |> get("/api/v1/filters/#{filter.filter_id}") |> json_response_and_validate_schema(200)
 
-    conn = get(conn, "/api/v1/filters/#{filter.filter_id}")
-
-    assert response = json_response_and_validate_schema(conn, 200)
-    assert response["whole_word"] == true
+    assert resp2["whole_word"] == true
   end
 
-  test "update a filter" do
-    %{user: user, conn: conn} = oauth_access(["write:filters"])
+  test "get a filter not_found error" do
+    filter = insert(:filter)
+    %{conn: conn} = oauth_access(["read:filters"])
 
-    query = %Pleroma.Filter{
-      user_id: user.id,
-      filter_id: 2,
-      phrase: "knight",
-      context: ["home"],
-      hide: true,
-      whole_word: true
-    }
+    response =
+      conn |> get("/api/v1/filters/#{filter.filter_id}") |> json_response_and_validate_schema(404)
 
-    {:ok, _filter} = Pleroma.Filter.create(query)
+    assert response["error"] == "Record not found"
+  end
+
+  describe "updating a filter" do
+    setup do: oauth_access(["write:filters"])
+
+    test "common" do
+      %{conn: conn, user: user} = oauth_access(["write:filters"])
+
+      filter =
+        insert(:filter,
+          user: user,
+          hide: true,
+          whole_word: true
+        )
+
+      params = %{
+        phrase: "nii",
+        context: ["public"],
+        irreversible: false
+      }
+
+      response =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> put("/api/v1/filters/#{filter.filter_id}", params)
+        |> json_response_and_validate_schema(200)
+
+      assert response["phrase"] == params.phrase
+      assert response["context"] == params.context
+      assert response["irreversible"] == false
+      assert response["whole_word"] == true
+    end
+
+    test "with adding expires_at", %{conn: conn, user: user} do
+      filter = insert(:filter, user: user)
+      in_seconds = 600
+
+      response =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> put("/api/v1/filters/#{filter.filter_id}", %{
+          phrase: "nii",
+          context: ["public"],
+          expires_in: in_seconds,
+          irreversible: true
+        })
+        |> json_response_and_validate_schema(200)
+
+      assert response["irreversible"] == true
+
+      assert response["expires_at"] ==
+               NaiveDateTime.utc_now()
+               |> NaiveDateTime.add(in_seconds)
+               |> Pleroma.Web.CommonAPI.Utils.to_masto_date()
+
+      filter = Filter.get(response["id"], user)
+
+      id = filter.id
+
+      assert_enqueued(
+        worker: PurgeExpiredFilter,
+        args: %{filter_id: id}
+      )
+
+      assert {:ok, %{id: ^id}} =
+               perform_job(PurgeExpiredFilter, %{
+                 filter_id: id
+               })
+
+      assert Repo.aggregate(Filter, :count, :id) == 0
+    end
+
+    test "with removing expires_at", %{conn: conn, user: user} do
+      response =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> post("/api/v1/filters", %{
+          phrase: "cofe",
+          context: ["home"],
+          expires_in: 600
+        })
+        |> json_response_and_validate_schema(200)
+
+      filter = Filter.get(response["id"], user)
+
+      assert_enqueued(
+        worker: PurgeExpiredFilter,
+        args: %{filter_id: filter.id}
+      )
+
+      response =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> put("/api/v1/filters/#{filter.filter_id}", %{
+          phrase: "nii",
+          context: ["public"],
+          expires_in: nil,
+          whole_word: true
+        })
+        |> json_response_and_validate_schema(200)
+
+      refute_enqueued(
+        worker: PurgeExpiredFilter,
+        args: %{filter_id: filter.id}
+      )
+
+      assert response["irreversible"] == false
+      assert response["whole_word"] == true
+      assert response["expires_at"] == nil
+    end
+
+    test "expires_at is the same in create and update so job is in db", %{conn: conn, user: user} do
+      resp1 =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> post("/api/v1/filters", %{
+          phrase: "cofe",
+          context: ["home"],
+          expires_in: 600
+        })
+        |> json_response_and_validate_schema(200)
+
+      filter = Filter.get(resp1["id"], user)
+
+      assert_enqueued(
+        worker: PurgeExpiredFilter,
+        args: %{filter_id: filter.id}
+      )
+
+      job = PurgeExpiredFilter.get_expiration(filter.id)
+
+      resp2 =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> put("/api/v1/filters/#{filter.filter_id}", %{
+          phrase: "nii",
+          context: ["public"]
+        })
+        |> json_response_and_validate_schema(200)
+
+      updated_filter = Filter.get(resp2["id"], user)
+
+      assert_enqueued(
+        worker: PurgeExpiredFilter,
+        args: %{filter_id: updated_filter.id}
+      )
+
+      after_update = PurgeExpiredFilter.get_expiration(updated_filter.id)
+
+      assert resp1["expires_at"] == resp2["expires_at"]
+
+      assert job.scheduled_at == after_update.scheduled_at
+    end
+
+    test "updating expires_at updates oban job too", %{conn: conn, user: user} do
+      resp1 =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> post("/api/v1/filters", %{
+          phrase: "cofe",
+          context: ["home"],
+          expires_in: 600
+        })
+        |> json_response_and_validate_schema(200)
+
+      filter = Filter.get(resp1["id"], user)
+
+      assert_enqueued(
+        worker: PurgeExpiredFilter,
+        args: %{filter_id: filter.id}
+      )
+
+      job = PurgeExpiredFilter.get_expiration(filter.id)
+
+      resp2 =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> put("/api/v1/filters/#{filter.filter_id}", %{
+          phrase: "nii",
+          context: ["public"],
+          expires_in: 300
+        })
+        |> json_response_and_validate_schema(200)
+
+      updated_filter = Filter.get(resp2["id"], user)
+
+      assert_enqueued(
+        worker: PurgeExpiredFilter,
+        args: %{filter_id: updated_filter.id}
+      )
+
+      after_update = PurgeExpiredFilter.get_expiration(updated_filter.id)
+
+      refute resp1["expires_at"] == resp2["expires_at"]
+
+      refute job.scheduled_at == after_update.scheduled_at
+    end
+  end
 
-    new = %Pleroma.Filter{
-      phrase: "nii",
-      context: ["home"]
-    }
+  test "update filter without token", %{conn: conn} do
+    filter = insert(:filter)
 
-    conn =
+    response =
       conn
       |> put_req_header("content-type", "application/json")
-      |> put("/api/v1/filters/#{query.filter_id}", %{
-        phrase: new.phrase,
-        context: new.context
+      |> put("/api/v1/filters/#{filter.filter_id}", %{
+        phrase: "nii",
+        context: ["public"]
       })
+      |> json_response(403)
 
-    assert response = json_response_and_validate_schema(conn, 200)
-    assert response["phrase"] == new.phrase
-    assert response["context"] == new.context
-    assert response["irreversible"] == true
-    assert response["whole_word"] == true
+    assert response["error"] == "Invalid credentials."
   end
 
-  test "delete a filter" do
-    %{user: user, conn: conn} = oauth_access(["write:filters"])
-
-    query = %Pleroma.Filter{
-      user_id: user.id,
-      filter_id: 2,
-      phrase: "knight",
-      context: ["home"]
-    }
+  describe "delete a filter" do
+    setup do: oauth_access(["write:filters"])
+
+    test "common", %{conn: conn, user: user} do
+      filter = insert(:filter, user: user)
+
+      assert conn
+             |> delete("/api/v1/filters/#{filter.filter_id}")
+             |> json_response_and_validate_schema(200) == %{}
+
+      assert Repo.all(Filter) == []
+    end
+
+    test "with expires_at", %{conn: conn, user: user} do
+      response =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> post("/api/v1/filters", %{
+          phrase: "cofe",
+          context: ["home"],
+          expires_in: 600
+        })
+        |> json_response_and_validate_schema(200)
+
+      filter = Filter.get(response["id"], user)
+
+      assert_enqueued(
+        worker: PurgeExpiredFilter,
+        args: %{filter_id: filter.id}
+      )
+
+      assert conn
+             |> delete("/api/v1/filters/#{filter.filter_id}")
+             |> json_response_and_validate_schema(200) == %{}
+
+      refute_enqueued(
+        worker: PurgeExpiredFilter,
+        args: %{filter_id: filter.id}
+      )
+
+      assert Repo.all(Filter) == []
+      assert Repo.all(Oban.Job) == []
+    end
+  end
 
-    {:ok, filter} = Pleroma.Filter.create(query)
+  test "delete a filter without token", %{conn: conn} do
+    filter = insert(:filter)
 
-    conn = delete(conn, "/api/v1/filters/#{filter.filter_id}")
+    response =
+      conn
+      |> delete("/api/v1/filters/#{filter.filter_id}")
+      |> json_response(403)
 
-    assert json_response_and_validate_schema(conn, 200) == %{}
+    assert response["error"] == "Invalid credentials."
   end
 end
diff --git a/test/pleroma/workers/purge_expired_filter_test.exs b/test/pleroma/workers/purge_expired_filter_test.exs
new file mode 100644 (file)
index 0000000..d10586b
--- /dev/null
@@ -0,0 +1,30 @@
+defmodule Pleroma.Workers.PurgeExpiredFilterTest do
+  use Pleroma.DataCase, async: true
+  use Oban.Testing, repo: Repo
+
+  import Pleroma.Factory
+
+  test "purges expired filter" do
+    %{id: user_id} = insert(:user)
+
+    {:ok, %{id: id}} =
+      Pleroma.Filter.create(%{
+        user_id: user_id,
+        phrase: "cofe",
+        context: ["home"],
+        expires_in: 600
+      })
+
+    assert_enqueued(
+      worker: Pleroma.Workers.PurgeExpiredFilter,
+      args: %{filter_id: id}
+    )
+
+    assert {:ok, %{id: ^id}} =
+             perform_job(Pleroma.Workers.PurgeExpiredFilter, %{
+               filter_id: id
+             })
+
+    assert Repo.aggregate(Pleroma.Filter, :count, :id) == 0
+  end
+end
index bf9592064ed7c63eda4d16e508c36f074f0b7e23..284d573f970528f8d11b7e397559cf00d361a1d6 100644 (file)
@@ -455,7 +455,8 @@ defmodule Pleroma.Factory do
     %Pleroma.Filter{
       user: build(:user),
       filter_id: sequence(:filter_id, & &1),
-      phrase: "cofe"
+      phrase: "cofe",
+      context: ["home"]
     }
   end
 end