Page MenuHomePhorge

No OneTemporary

Size
36 KB
Referenced Files
None
Subscribers
None
diff --git a/changelog.d/hashtag-search.change b/changelog.d/hashtag-search.change
new file mode 100644
index 000000000..f17e711ce
--- /dev/null
+++ b/changelog.d/hashtag-search.change
@@ -0,0 +1 @@
+Hashtag searches return real results based on words in your query
diff --git a/lib/pleroma/hashtag.ex b/lib/pleroma/hashtag.ex
index 3682f0c14..91c30c6e7 100644
--- a/lib/pleroma/hashtag.ex
+++ b/lib/pleroma/hashtag.ex
@@ -1,133 +1,195 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Hashtag do
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query
alias Ecto.Multi
alias Pleroma.Hashtag
alias Pleroma.Object
alias Pleroma.Repo
alias Pleroma.User.HashtagFollow
schema "hashtags" do
field(:name, :string)
many_to_many(:objects, Object, join_through: "hashtags_objects", on_replace: :delete)
timestamps()
end
def normalize_name(name) do
name
|> String.downcase()
|> 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})
Repo.insert(
changeset,
on_conflict: [set: [name: get_field(changeset, :name)]],
conflict_target: :name,
returning: true
)
end
def get_or_create_by_names(names) when is_list(names) do
names = Enum.map(names, &normalize_name/1)
timestamp = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second)
structs =
Enum.map(names, fn name ->
%Hashtag{}
|> changeset(%{name: name})
|> Map.get(:changes)
|> Map.merge(%{inserted_at: timestamp, updated_at: timestamp})
end)
try do
with {:ok, %{query_op: hashtags}} <-
Multi.new()
|> Multi.insert_all(:insert_all_op, Hashtag, structs,
on_conflict: :nothing,
conflict_target: :name
)
|> Multi.run(:query_op, fn _repo, _changes ->
{:ok, Repo.all(from(ht in Hashtag, where: ht.name in ^names))}
end)
|> Repo.transaction() do
{:ok, hashtags}
else
{:error, _name, value, _changes_so_far} -> {:error, value}
end
rescue
e -> {:error, e}
end
end
def changeset(%Hashtag{} = struct, params) do
struct
|> cast(params, [:name])
|> update_change(:name, &normalize_name/1)
|> validate_required([:name])
|> unique_constraint(:name)
end
def unlink(%Object{id: object_id}) do
with {_, hashtag_ids} <-
from(hto in "hashtags_objects",
where: hto.object_id == ^object_id,
select: hto.hashtag_id
)
|> Repo.delete_all(),
{:ok, unreferenced_count} <- delete_unreferenced(hashtag_ids) do
{:ok, length(hashtag_ids), unreferenced_count}
end
end
@delete_unreferenced_query """
DELETE FROM hashtags WHERE id IN
(SELECT hashtags.id FROM hashtags
LEFT OUTER JOIN hashtags_objects
ON hashtags_objects.hashtag_id = hashtags.id
WHERE hashtags_objects.hashtag_id IS NULL AND hashtags.id = ANY($1));
"""
def delete_unreferenced(ids) do
with {:ok, %{num_rows: deleted_count}} <- Repo.query(@delete_unreferenced_query, [ids]) 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: []
+
+ def search(query, options \\ []) do
+ limit = Keyword.get(options, :limit, 20)
+ offset = Keyword.get(options, :offset, 0)
+
+ search_terms =
+ query
+ |> String.downcase()
+ |> String.trim()
+ |> String.split(~r/\s+/)
+ |> Enum.filter(&(&1 != ""))
+ |> Enum.map(&String.trim_leading(&1, "#"))
+ |> Enum.filter(&(&1 != ""))
+
+ if Enum.empty?(search_terms) do
+ []
+ else
+ # Use PostgreSQL's ANY operator with array for efficient multi-term search
+ # This is much more efficient than multiple OR clauses
+ search_patterns = Enum.map(search_terms, &"%#{&1}%")
+
+ # Create ranking query that prioritizes exact matches and closer matches
+ # Use a subquery to properly handle computed columns in ORDER BY
+ base_query =
+ from(ht in Hashtag,
+ where: fragment("LOWER(?) LIKE ANY(?)", ht.name, ^search_patterns),
+ select: %{
+ name: ht.name,
+ # Ranking: exact matches get highest priority (0)
+ # then prefix matches (1), then contains (2)
+ match_rank:
+ fragment(
+ """
+ CASE
+ WHEN LOWER(?) = ANY(?) THEN 0
+ WHEN LOWER(?) LIKE ANY(?) THEN 1
+ ELSE 2
+ END
+ """,
+ ht.name,
+ ^search_terms,
+ ht.name,
+ ^Enum.map(search_terms, &"#{&1}%")
+ ),
+ # Secondary sort by name length (shorter names first)
+ name_length: fragment("LENGTH(?)", ht.name)
+ }
+ )
+
+ from(result in subquery(base_query),
+ order_by: [
+ asc: result.match_rank,
+ asc: result.name_length,
+ asc: result.name
+ ],
+ limit: ^limit,
+ offset: ^offset
+ )
+ |> Repo.all()
+ |> Enum.map(& &1.name)
+ end
+ end
end
diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex
index d9a1ba41e..53f1216fd 100644
--- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex
@@ -1,202 +1,148 @@
# 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.SearchController do
use Pleroma.Web, :controller
+ alias Pleroma.Hashtag
alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.Web.ControllerHelper
alias Pleroma.Web.Endpoint
alias Pleroma.Web.MastodonAPI.AccountView
alias Pleroma.Web.MastodonAPI.StatusView
alias Pleroma.Web.Plugs.OAuthScopesPlug
alias Pleroma.Web.Plugs.RateLimiter
require Logger
@search_limit 40
plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false)
# Note: Mastodon doesn't allow unauthenticated access (requires read:accounts / read:search)
plug(OAuthScopesPlug, %{scopes: ["read:search"], fallback: :proceed_unauthenticated})
# Note: on private instances auth is required (EnsurePublicOrAuthenticatedPlug is not skipped)
plug(RateLimiter, [name: :search] when action in [:search, :search2, :account_search])
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.SearchOperation
def account_search(
%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{q: query} = params}}} =
conn,
_
) do
accounts = User.search(query, search_options(params, user))
conn
|> put_view(AccountView)
|> render("index.json",
users: accounts,
for: user,
as: :user
)
end
def search2(conn, params), do: do_search(:v2, conn, params)
def search(conn, params), do: do_search(:v1, conn, params)
defp do_search(
version,
%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{q: query} = params}}} =
conn,
_
) do
query = String.trim(query)
options = search_options(params, user)
timeout = Keyword.get(Repo.config(), :timeout, 15_000)
default_values = %{"statuses" => [], "accounts" => [], "hashtags" => []}
result =
default_values
|> Enum.map(fn {resource, default_value} ->
if params[:type] in [nil, resource] do
{resource, fn -> resource_search(version, resource, query, options) end}
else
{resource, fn -> default_value end}
end
end)
|> Task.async_stream(fn {resource, f} -> {resource, with_fallback(f)} end,
timeout: timeout,
on_timeout: :kill_task
)
|> Enum.reduce(default_values, fn
{:ok, {resource, result}}, acc ->
Map.put(acc, resource, result)
_error, acc ->
acc
end)
json(conn, result)
end
defp search_options(params, user) do
[
resolve: params[:resolve],
following: params[:following],
limit: min(params[:limit], @search_limit),
offset: params[:offset],
type: params[:type],
author: get_author(params),
embed_relationships: ControllerHelper.embed_relationships?(params),
for_user: user
]
|> Enum.filter(&elem(&1, 1))
end
defp resource_search(_, "accounts", query, options) do
accounts = with_fallback(fn -> User.search(query, options) end)
AccountView.render("index.json",
users: accounts,
for: options[:for_user],
embed_relationships: options[:embed_relationships]
)
end
defp resource_search(_, "statuses", query, options) do
statuses = with_fallback(fn -> Pleroma.Search.search(query, options) end)
StatusView.render("index.json",
activities: statuses,
for: options[:for_user],
as: :activity
)
end
defp resource_search(:v2, "hashtags", query, options) do
tags_path = Endpoint.url() <> "/tag/"
- query
- |> prepare_tags(options)
+ Hashtag.search(query, options)
|> Enum.map(fn tag ->
%{name: tag, url: tags_path <> tag}
end)
end
defp resource_search(:v1, "hashtags", query, options) do
- prepare_tags(query, options)
- end
-
- defp prepare_tags(query, options) do
- tags =
- query
- |> preprocess_uri_query()
- |> String.split(~r/[^#\w]+/u, trim: true)
- |> Enum.uniq_by(&String.downcase/1)
-
- explicit_tags = Enum.filter(tags, fn tag -> String.starts_with?(tag, "#") end)
-
- tags =
- if Enum.any?(explicit_tags) do
- explicit_tags
- else
- tags
- end
-
- tags = Enum.map(tags, fn tag -> String.trim_leading(tag, "#") end)
-
- tags =
- if Enum.empty?(explicit_tags) && !options[:skip_joined_tag] do
- add_joined_tag(tags)
- else
- tags
- end
-
- Pleroma.Pagination.paginate_list(tags, options)
- end
-
- defp add_joined_tag(tags) do
- tags
- |> Kernel.++([joined_tag(tags)])
- |> Enum.uniq_by(&String.downcase/1)
- end
-
- # If `query` is a URI, returns last component of its path, otherwise returns `query`
- defp preprocess_uri_query(query) do
- if query =~ ~r/https?:\/\// do
- query
- |> String.trim_trailing("/")
- |> URI.parse()
- |> Map.get(:path)
- |> String.split("/")
- |> Enum.at(-1)
- else
- query
- end
- end
-
- defp joined_tag(tags) do
- tags
- |> Enum.map(fn tag -> String.capitalize(tag) end)
- |> Enum.join()
+ Hashtag.search(query, options)
end
defp with_fallback(f, fallback \\ []) do
try do
f.()
rescue
error ->
Logger.error(Exception.format(:error, error, __STACKTRACE__))
fallback
end
end
defp get_author(%{account_id: account_id}) when is_binary(account_id),
do: User.get_cached_by_id(account_id)
defp get_author(_params), do: nil
end
diff --git a/test/pleroma/hashtag_test.exs b/test/pleroma/hashtag_test.exs
index 8531b1879..0e16b8155 100644
--- a/test/pleroma/hashtag_test.exs
+++ b/test/pleroma/hashtag_test.exs
@@ -1,17 +1,146 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.HashtagTest do
use Pleroma.DataCase
alias Pleroma.Hashtag
describe "changeset validations" do
test "ensure non-blank :name" do
changeset = Hashtag.changeset(%Hashtag{}, %{name: ""})
assert {:name, {"can't be blank", [validation: :required]}} in changeset.errors
end
end
+
+ describe "search_hashtags" do
+ test "searches hashtags by partial match" do
+ {:ok, _} = Hashtag.get_or_create_by_name("car")
+ {:ok, _} = Hashtag.get_or_create_by_name("racecar")
+ {:ok, _} = Hashtag.get_or_create_by_name("nascar")
+ {:ok, _} = Hashtag.get_or_create_by_name("bicycle")
+
+ results = Hashtag.search("car")
+ assert "car" in results
+ assert "racecar" in results
+ assert "nascar" in results
+ refute "bicycle" in results
+
+ results = Hashtag.search("race")
+ assert "racecar" in results
+ refute "car" in results
+ refute "nascar" in results
+ refute "bicycle" in results
+
+ results = Hashtag.search("nonexistent")
+ assert results == []
+ end
+
+ test "searches hashtags by multiple words in query" do
+ {:ok, _} = Hashtag.get_or_create_by_name("computer")
+ {:ok, _} = Hashtag.get_or_create_by_name("laptop")
+ {:ok, _} = Hashtag.get_or_create_by_name("desktop")
+ {:ok, _} = Hashtag.get_or_create_by_name("phone")
+
+ # Search for "new computer" - should return "computer"
+ results = Hashtag.search("new computer")
+ assert "computer" in results
+ refute "laptop" in results
+ refute "desktop" in results
+ refute "phone" in results
+
+ # Search for "computer laptop" - should return both
+ results = Hashtag.search("computer laptop")
+ assert "computer" in results
+ assert "laptop" in results
+ refute "desktop" in results
+ refute "phone" in results
+
+ # Search for "new phone" - should return "phone"
+ results = Hashtag.search("new phone")
+ assert "phone" in results
+ refute "computer" in results
+ refute "laptop" in results
+ refute "desktop" in results
+ end
+
+ test "supports pagination" do
+ {:ok, _} = Hashtag.get_or_create_by_name("alpha")
+ {:ok, _} = Hashtag.get_or_create_by_name("beta")
+ {:ok, _} = Hashtag.get_or_create_by_name("gamma")
+ {:ok, _} = Hashtag.get_or_create_by_name("delta")
+
+ results = Hashtag.search("a", limit: 2)
+ assert length(results) == 2
+
+ results = Hashtag.search("a", limit: 2, offset: 1)
+ assert length(results) == 2
+ end
+
+ test "handles matching many search terms" do
+ {:ok, _} = Hashtag.get_or_create_by_name("computer")
+ {:ok, _} = Hashtag.get_or_create_by_name("laptop")
+ {:ok, _} = Hashtag.get_or_create_by_name("phone")
+ {:ok, _} = Hashtag.get_or_create_by_name("tablet")
+
+ results = Hashtag.search("new fast computer laptop phone tablet device")
+ assert "computer" in results
+ assert "laptop" in results
+ assert "phone" in results
+ assert "tablet" in results
+ end
+
+ test "ranks results by match quality" do
+ {:ok, _} = Hashtag.get_or_create_by_name("my_computer")
+ {:ok, _} = Hashtag.get_or_create_by_name("computer_science")
+ {:ok, _} = Hashtag.get_or_create_by_name("computer")
+
+ results = Hashtag.search("computer")
+
+ # Exact match first
+ assert Enum.at(results, 0) == "computer"
+
+ # Prefix match would be next
+ assert Enum.at(results, 1) == "computer_science"
+
+ # worst match is last
+ assert Enum.at(results, 2) == "my_computer"
+ end
+
+ test "prioritizes shorter names when ranking is equal" do
+ # Create hashtags with same ranking but different lengths
+ {:ok, _} = Hashtag.get_or_create_by_name("car")
+ {:ok, _} = Hashtag.get_or_create_by_name("racecar")
+ {:ok, _} = Hashtag.get_or_create_by_name("nascar")
+
+ # Search for "car" - shorter names should come first
+ results = Hashtag.search("car")
+ # Shortest exact match first
+ assert Enum.at(results, 0) == "car"
+ assert "racecar" in results
+ assert "nascar" in results
+ end
+
+ test "handles hashtag symbols in search query" do
+ {:ok, _} = Hashtag.get_or_create_by_name("computer")
+ {:ok, _} = Hashtag.get_or_create_by_name("laptop")
+ {:ok, _} = Hashtag.get_or_create_by_name("phone")
+
+ results_with_hash = Hashtag.search("#computer #laptop")
+ results_without_hash = Hashtag.search("computer laptop")
+
+ assert results_with_hash == results_without_hash
+
+ results_mixed = Hashtag.search("#computer laptop #phone")
+ assert "computer" in results_mixed
+ assert "laptop" in results_mixed
+ assert "phone" in results_mixed
+
+ results_only_hash = Hashtag.search("#computer")
+ results_no_hash = Hashtag.search("computer")
+ assert results_only_hash == results_no_hash
+ end
+ end
end
diff --git a/test/pleroma/web/mastodon_api/controllers/search_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/search_controller_test.exs
index d8263dfad..f0c9c1901 100644
--- a/test/pleroma/web/mastodon_api/controllers/search_controller_test.exs
+++ b/test/pleroma/web/mastodon_api/controllers/search_controller_test.exs
@@ -1,453 +1,461 @@
# 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.SearchControllerTest do
use Pleroma.Web.ConnCase
alias Pleroma.Object
alias Pleroma.Web.CommonAPI
- alias Pleroma.Web.Endpoint
import Pleroma.Factory
import ExUnit.CaptureLog
import Tesla.Mock
import Mock
setup do
Mox.stub_with(Pleroma.UnstubbedConfigMock, Pleroma.Test.StaticConfig)
:ok
end
setup_all do
mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
:ok
end
describe ".search2" do
test "it returns empty result if user or status search return undefined error", %{conn: conn} do
with_mocks [
{Pleroma.User, [], [search: fn _q, _o -> raise "Oops" end]},
{Pleroma.Activity, [], [search: fn _u, _q, _o -> raise "Oops" end]}
] do
capture_log(fn ->
results =
conn
|> get("/api/v2/search?q=2hu")
|> json_response_and_validate_schema(200)
assert results["accounts"] == []
assert results["statuses"] == []
end) =~
"[error] Elixir.Pleroma.Web.MastodonAPI.SearchController search error: %RuntimeError{message: \"Oops\"}"
end
end
@tag :skip_darwin
test "search", %{conn: conn} do
user = insert(:user)
user_two = insert(:user, %{nickname: "shp@shitposter.club"})
user_three = insert(:user, %{nickname: "shp@heldscal.la", name: "I love 2hu"})
{:ok, activity} = CommonAPI.post(user, %{status: "This is about 2hu private 天子"})
{:ok, _activity} =
CommonAPI.post(user, %{
status: "This is about 2hu, but private",
visibility: "private"
})
{:ok, _} = CommonAPI.post(user_two, %{status: "This isn't"})
results =
conn
|> get("/api/v2/search?#{URI.encode_query(%{q: "2hu #private"})}")
|> json_response_and_validate_schema(200)
[account | _] = results["accounts"]
assert account["id"] == to_string(user_three.id)
- assert results["hashtags"] == [
- %{"name" => "private", "url" => "#{Endpoint.url()}/tag/private"}
- ]
+ assert results["hashtags"] == []
[status] = results["statuses"]
assert status["id"] == to_string(activity.id)
results =
get(conn, "/api/v2/search?q=天子")
|> json_response_and_validate_schema(200)
- assert results["hashtags"] == [
- %{"name" => "天子", "url" => "#{Endpoint.url()}/tag/天子"}
- ]
+ assert results["hashtags"] == []
[status] = results["statuses"]
assert status["id"] == to_string(activity.id)
end
test "search local-only status as an authenticated user" do
user = insert(:user)
%{conn: conn} = oauth_access(["read:search"])
{:ok, activity} =
CommonAPI.post(user, %{status: "This is about 2hu private 天子", visibility: "local"})
results =
conn
|> get("/api/v2/search?#{URI.encode_query(%{q: "2hu"})}")
|> json_response_and_validate_schema(200)
[status] = results["statuses"]
assert status["id"] == to_string(activity.id)
end
test "search local-only status as an unauthenticated user" do
user = insert(:user)
%{conn: conn} = oauth_access([])
{:ok, _activity} =
CommonAPI.post(user, %{status: "This is about 2hu private 天子", visibility: "local"})
results =
conn
|> get("/api/v2/search?#{URI.encode_query(%{q: "2hu"})}")
|> json_response_and_validate_schema(200)
assert [] = results["statuses"]
end
test "search local-only status as an anonymous user" do
user = insert(:user)
{:ok, _activity} =
CommonAPI.post(user, %{status: "This is about 2hu private 天子", visibility: "local"})
results =
build_conn()
|> get("/api/v2/search?#{URI.encode_query(%{q: "2hu"})}")
|> json_response_and_validate_schema(200)
assert [] = results["statuses"]
end
- test "constructs hashtags from search query", %{conn: conn} do
+ test "returns empty results when no hashtags match", %{conn: conn} do
results =
conn
- |> get("/api/v2/search?#{URI.encode_query(%{q: "some text with #explicit #hashtags"})}")
+ |> get("/api/v2/search?#{URI.encode_query(%{q: "nonexistent"})}")
|> json_response_and_validate_schema(200)
- assert results["hashtags"] == [
- %{"name" => "explicit", "url" => "#{Endpoint.url()}/tag/explicit"},
- %{"name" => "hashtags", "url" => "#{Endpoint.url()}/tag/hashtags"}
- ]
+ assert results["hashtags"] == []
+ end
+
+ test "searches hashtags by multiple words in query", %{conn: conn} do
+ user = insert(:user)
+
+ {:ok, _activity1} = CommonAPI.post(user, %{status: "This is my new #computer"})
+ {:ok, _activity2} = CommonAPI.post(user, %{status: "Check out this #laptop"})
+ {:ok, _activity3} = CommonAPI.post(user, %{status: "My #desktop setup"})
+ {:ok, _activity4} = CommonAPI.post(user, %{status: "New #phone arrived"})
results =
conn
- |> get("/api/v2/search?#{URI.encode_query(%{q: "john doe JOHN DOE"})}")
+ |> get("/api/v2/search?#{URI.encode_query(%{q: "new computer"})}")
|> json_response_and_validate_schema(200)
- assert results["hashtags"] == [
- %{"name" => "john", "url" => "#{Endpoint.url()}/tag/john"},
- %{"name" => "doe", "url" => "#{Endpoint.url()}/tag/doe"},
- %{"name" => "JohnDoe", "url" => "#{Endpoint.url()}/tag/JohnDoe"}
- ]
+ hashtag_names = Enum.map(results["hashtags"], & &1["name"])
+ assert "computer" in hashtag_names
+ refute "laptop" in hashtag_names
+ refute "desktop" in hashtag_names
+ refute "phone" in hashtag_names
results =
conn
- |> get("/api/v2/search?#{URI.encode_query(%{q: "accident-prone"})}")
+ |> get("/api/v2/search?#{URI.encode_query(%{q: "computer laptop"})}")
|> json_response_and_validate_schema(200)
- assert results["hashtags"] == [
- %{"name" => "accident", "url" => "#{Endpoint.url()}/tag/accident"},
- %{"name" => "prone", "url" => "#{Endpoint.url()}/tag/prone"},
- %{"name" => "AccidentProne", "url" => "#{Endpoint.url()}/tag/AccidentProne"}
- ]
+ hashtag_names = Enum.map(results["hashtags"], & &1["name"])
+ assert "computer" in hashtag_names
+ assert "laptop" in hashtag_names
+ refute "desktop" in hashtag_names
+ refute "phone" in hashtag_names
+ end
+
+ test "supports pagination of hashtags search results", %{conn: conn} do
+ user = insert(:user)
+
+ {:ok, _activity1} = CommonAPI.post(user, %{status: "First #alpha hashtag"})
+ {:ok, _activity2} = CommonAPI.post(user, %{status: "Second #beta hashtag"})
+ {:ok, _activity3} = CommonAPI.post(user, %{status: "Third #gamma hashtag"})
+ {:ok, _activity4} = CommonAPI.post(user, %{status: "Fourth #delta hashtag"})
results =
conn
- |> get("/api/v2/search?#{URI.encode_query(%{q: "https://shpposter.club/users/shpuld"})}")
+ |> get("/api/v2/search?#{URI.encode_query(%{q: "a", limit: 2, offset: 1})}")
|> json_response_and_validate_schema(200)
- assert results["hashtags"] == [
- %{"name" => "shpuld", "url" => "#{Endpoint.url()}/tag/shpuld"}
- ]
+ hashtag_names = Enum.map(results["hashtags"], & &1["name"])
+
+ # Should return 2 hashtags (alpha, beta, gamma, delta all contain 'a')
+ # With offset 1, we skip the first one, so we get 2 of the remaining 3
+ assert length(hashtag_names) == 2
+ assert Enum.all?(hashtag_names, &String.contains?(&1, "a"))
+ end
+
+ test "searches real hashtags from database", %{conn: conn} do
+ user = insert(:user)
+
+ {:ok, _activity1} = CommonAPI.post(user, %{status: "Check out this #car"})
+ {:ok, _activity2} = CommonAPI.post(user, %{status: "Fast #racecar on the track"})
+ {:ok, _activity3} = CommonAPI.post(user, %{status: "NASCAR #nascar racing"})
results =
conn
- |> get(
- "/api/v2/search?#{URI.encode_query(%{q: "https://www.washingtonpost.com/sports/2020/06/10/" <> "nascar-ban-display-confederate-flag-all-events-properties/"})}"
- )
+ |> get("/api/v2/search?#{URI.encode_query(%{q: "car"})}")
|> json_response_and_validate_schema(200)
- assert results["hashtags"] == [
- %{"name" => "nascar", "url" => "#{Endpoint.url()}/tag/nascar"},
- %{"name" => "ban", "url" => "#{Endpoint.url()}/tag/ban"},
- %{"name" => "display", "url" => "#{Endpoint.url()}/tag/display"},
- %{"name" => "confederate", "url" => "#{Endpoint.url()}/tag/confederate"},
- %{"name" => "flag", "url" => "#{Endpoint.url()}/tag/flag"},
- %{"name" => "all", "url" => "#{Endpoint.url()}/tag/all"},
- %{"name" => "events", "url" => "#{Endpoint.url()}/tag/events"},
- %{"name" => "properties", "url" => "#{Endpoint.url()}/tag/properties"},
- %{
- "name" => "NascarBanDisplayConfederateFlagAllEventsProperties",
- "url" =>
- "#{Endpoint.url()}/tag/NascarBanDisplayConfederateFlagAllEventsProperties"
- }
- ]
- end
+ hashtag_names = Enum.map(results["hashtags"], & &1["name"])
- test "supports pagination of hashtags search results", %{conn: conn} do
+ # Should return car, racecar, and nascar since they all contain "car"
+ assert "car" in hashtag_names
+ assert "racecar" in hashtag_names
+ assert "nascar" in hashtag_names
+
+ # Search for "race" - should return racecar
results =
conn
- |> get(
- "/api/v2/search?#{URI.encode_query(%{q: "#some #text #with #hashtags", limit: 2, offset: 1})}"
- )
+ |> get("/api/v2/search?#{URI.encode_query(%{q: "race"})}")
|> json_response_and_validate_schema(200)
- assert results["hashtags"] == [
- %{"name" => "text", "url" => "#{Endpoint.url()}/tag/text"},
- %{"name" => "with", "url" => "#{Endpoint.url()}/tag/with"}
- ]
+ hashtag_names = Enum.map(results["hashtags"], & &1["name"])
+
+ assert "racecar" in hashtag_names
+ refute "car" in hashtag_names
+ refute "nascar" in hashtag_names
end
test "excludes a blocked users from search results", %{conn: conn} do
user = insert(:user)
user_smith = insert(:user, %{nickname: "Agent", name: "I love 2hu"})
user_neo = insert(:user, %{nickname: "Agent Neo", name: "Agent"})
{:ok, act1} = CommonAPI.post(user, %{status: "This is about 2hu private 天子"})
{:ok, act2} = CommonAPI.post(user_smith, %{status: "Agent Smith"})
{:ok, act3} = CommonAPI.post(user_neo, %{status: "Agent Smith"})
Pleroma.User.block(user, user_smith)
results =
conn
|> assign(:user, user)
|> assign(:token, insert(:oauth_token, user: user, scopes: ["read"]))
|> get("/api/v2/search?q=Agent")
|> json_response_and_validate_schema(200)
status_ids = Enum.map(results["statuses"], fn g -> g["id"] end)
assert act3.id in status_ids
refute act2.id in status_ids
refute act1.id in status_ids
end
end
describe ".account_search" do
test "account search", %{conn: conn} do
user_two = insert(:user, %{nickname: "shp@shitposter.club"})
user_three = insert(:user, %{nickname: "shp@heldscal.la", name: "I love 2hu"})
results =
conn
|> get("/api/v1/accounts/search?q=shp")
|> json_response_and_validate_schema(200)
result_ids = for result <- results, do: result["acct"]
assert user_two.nickname in result_ids
assert user_three.nickname in result_ids
results =
conn
|> get("/api/v1/accounts/search?q=2hu")
|> json_response_and_validate_schema(200)
result_ids = for result <- results, do: result["acct"]
assert user_three.nickname in result_ids
end
test "returns account if query contains a space", %{conn: conn} do
insert(:user, %{nickname: "shp@shitposter.club"})
results =
conn
|> get("/api/v1/accounts/search?q=shp@shitposter.club xxx")
|> json_response_and_validate_schema(200)
assert length(results) == 1
end
end
describe ".search" do
test "it returns empty result if user or status search return undefined error", %{conn: conn} do
with_mocks [
{Pleroma.User, [], [search: fn _q, _o -> raise "Oops" end]},
{Pleroma.Activity, [], [search: fn _u, _q, _o -> raise "Oops" end]}
] do
capture_log(fn ->
results =
conn
|> get("/api/v1/search?q=2hu")
|> json_response_and_validate_schema(200)
assert results["accounts"] == []
assert results["statuses"] == []
end) =~
"[error] Elixir.Pleroma.Web.MastodonAPI.SearchController search error: %RuntimeError{message: \"Oops\"}"
end
end
test "search", %{conn: conn} do
user = insert(:user)
user_two = insert(:user, %{nickname: "shp@shitposter.club"})
user_three = insert(:user, %{nickname: "shp@heldscal.la", name: "I love 2hu"})
{:ok, activity} = CommonAPI.post(user, %{status: "This is about 2hu"})
{:ok, _activity} =
CommonAPI.post(user, %{
status: "This is about 2hu, but private",
visibility: "private"
})
{:ok, _} = CommonAPI.post(user_two, %{status: "This isn't"})
results =
conn
|> get("/api/v1/search?q=2hu")
|> json_response_and_validate_schema(200)
[account | _] = results["accounts"]
assert account["id"] == to_string(user_three.id)
- assert results["hashtags"] == ["2hu"]
+ assert results["hashtags"] == []
[status] = results["statuses"]
assert status["id"] == to_string(activity.id)
end
test "search fetches remote statuses and prefers them over other results", %{conn: conn} do
{:ok, %{id: activity_id}} =
CommonAPI.post(insert(:user), %{
status: "check out http://mastodon.example.org/@admin/99541947525187367"
})
%{"url" => result_url, "id" => result_id} =
conn
|> get("/api/v1/search?q=http://mastodon.example.org/@admin/99541947525187367")
|> json_response_and_validate_schema(200)
|> Map.get("statuses")
|> List.first()
refute match?(^result_id, activity_id)
assert match?(^result_url, "http://mastodon.example.org/@admin/99541947525187367")
end
test "search doesn't show statuses that it shouldn't", %{conn: conn} do
{:ok, activity} =
CommonAPI.post(insert(:user), %{
status: "This is about 2hu, but private",
visibility: "private"
})
capture_log(fn ->
q = Object.normalize(activity, fetch: false).data["id"]
results =
conn
|> get("/api/v1/search?q=#{q}")
|> json_response_and_validate_schema(200)
[] = results["statuses"]
end)
end
test "search fetches remote accounts", %{conn: conn} do
user = insert(:user)
query = URI.encode_query(%{q: " mike@osada.macgirvin.com ", resolve: true})
results =
conn
|> assign(:user, user)
|> assign(:token, insert(:oauth_token, user: user, scopes: ["read"]))
|> get("/api/v1/search?#{query}")
|> json_response_and_validate_schema(200)
[account] = results["accounts"]
assert account["acct"] == "mike@osada.macgirvin.com"
end
test "search doesn't fetch remote accounts if resolve is false", %{conn: conn} do
results =
conn
|> get("/api/v1/search?q=mike@osada.macgirvin.com&resolve=false")
|> json_response_and_validate_schema(200)
assert [] == results["accounts"]
end
test "search with limit and offset", %{conn: conn} do
user = insert(:user)
_user_two = insert(:user, %{nickname: "shp@shitposter.club"})
_user_three = insert(:user, %{nickname: "shp@heldscal.la", name: "I love 2hu"})
{:ok, _activity1} = CommonAPI.post(user, %{status: "This is about 2hu"})
{:ok, _activity2} = CommonAPI.post(user, %{status: "This is also about 2hu"})
result =
conn
|> get("/api/v1/search?q=2hu&limit=1")
assert results = json_response_and_validate_schema(result, 200)
assert [%{"id" => activity_id1}] = results["statuses"]
assert [_] = results["accounts"]
results =
conn
|> get("/api/v1/search?q=2hu&limit=1&offset=1")
|> json_response_and_validate_schema(200)
assert [%{"id" => activity_id2}] = results["statuses"]
assert [] = results["accounts"]
assert activity_id1 != activity_id2
end
test "search returns results only for the given type", %{conn: conn} do
user = insert(:user)
_user_two = insert(:user, %{nickname: "shp@heldscal.la", name: "I love 2hu"})
{:ok, _activity} = CommonAPI.post(user, %{status: "This is about 2hu"})
assert %{"statuses" => [_activity], "accounts" => [], "hashtags" => []} =
conn
|> get("/api/v1/search?q=2hu&type=statuses")
|> json_response_and_validate_schema(200)
assert %{"statuses" => [], "accounts" => [_user_two], "hashtags" => []} =
conn
|> get("/api/v1/search?q=2hu&type=accounts")
|> json_response_and_validate_schema(200)
end
test "search uses account_id to filter statuses by the author", %{conn: conn} do
user = insert(:user, %{nickname: "shp@shitposter.club"})
user_two = insert(:user, %{nickname: "shp@heldscal.la", name: "I love 2hu"})
{:ok, activity1} = CommonAPI.post(user, %{status: "This is about 2hu"})
{:ok, activity2} = CommonAPI.post(user_two, %{status: "This is also about 2hu"})
results =
conn
|> get("/api/v1/search?q=2hu&account_id=#{user.id}")
|> json_response_and_validate_schema(200)
assert [%{"id" => activity_id1}] = results["statuses"]
assert activity_id1 == activity1.id
assert [_] = results["accounts"]
results =
conn
|> get("/api/v1/search?q=2hu&account_id=#{user_two.id}")
|> json_response_and_validate_schema(200)
assert [%{"id" => activity_id2}] = results["statuses"]
assert activity_id2 == activity2.id
end
end
end

File Metadata

Mime Type
text/x-diff
Expires
Fri, Sep 18, 11:17 PM (5 h, 3 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1768446
Default Alt Text
(36 KB)

Event Timeline