Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85710286
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
36 KB
Referenced Files
None
Subscribers
None
View Options
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
Details
Attached
Mime Type
text/x-diff
Expires
Fri, Sep 18, 11:17 PM (3 h, 16 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1768446
Default Alt Text
(36 KB)
Attached To
Mode
rPUBE pleroma-upstream
Attached
Detach File
Event Timeline
Log In to Comment