From bf6da6cc55bd0c49ead18a768590034405f08236 Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Thu, 21 Feb 2019 18:11:49 +0100 Subject: [PATCH] Implement search with PostgreSQL trigrams Signed-off-by: Thomas Citharel Rename function to reflect that we only get one result Signed-off-by: Thomas Citharel Add loggers and make Ecto call parallels during search Signed-off-by: Thomas Citharel Implement trigrams for events & replace pg similarity operator % with <% Signed-off-by: Thomas Citharel Fix tests Signed-off-by: Thomas Citharel --- lib/mobilizon/actors/actors.ex | 80 +++++++------ lib/mobilizon/actors/user.ex | 2 +- lib/mobilizon/events/events.ex | 19 ++- lib/mobilizon_web/api/search.ex | 99 ++++++++++++++++ lib/mobilizon_web/resolvers/event.ex | 34 ------ lib/mobilizon_web/resolvers/group.ex | 2 +- lib/mobilizon_web/resolvers/search.ex | 13 +++ lib/mobilizon_web/schema.ex | 2 +- lib/service/activity_pub/activity_pub.ex | 6 + mix.exs | 3 +- mix.lock | 1 + .../migrations/20180109150000_prerequites.exs | 22 ++++ .../20190219164310_create_search_indexes.exs | 48 ++++++++ test/mobilizon/actors/actors_test.exs | 31 +---- test/mobilizon/events/events_test.exs | 8 +- test/mobilizon_web/api/search_test.exs | 55 +++++++++ .../resolvers/event_resolver_test.exs | 29 ----- .../resolvers/search_resolver_test.exs | 109 ++++++++++++++++++ 18 files changed, 422 insertions(+), 141 deletions(-) create mode 100644 lib/mobilizon_web/api/search.ex create mode 100644 lib/mobilizon_web/resolvers/search.ex create mode 100644 priv/repo/migrations/20190219164310_create_search_indexes.exs create mode 100644 test/mobilizon_web/api/search_test.exs create mode 100644 test/mobilizon_web/resolvers/search_resolver_test.exs diff --git a/lib/mobilizon/actors/actors.ex b/lib/mobilizon/actors/actors.ex index 9118899c8..7b29fd91d 100644 --- a/lib/mobilizon/actors/actors.ex +++ b/lib/mobilizon/actors/actors.ex @@ -467,6 +467,11 @@ defmodule Mobilizon.Actors do |> Repo.preload(:organized_events) end + @doc """ + Getting an actor from url, eventually creating it + """ + # TODO: Move this to Mobilizon.Service.ActivityPub + @spec get_or_fetch_by_url(String.t(), bool()) :: {:ok, Actor.t()} | {:error, String.t()} def get_or_fetch_by_url(url, preload \\ false) do with {:ok, actor} <- get_actor_by_url(url, preload) do {:ok, actor} @@ -498,16 +503,32 @@ defmodule Mobilizon.Actors do end @doc """ - Find local users by it's username + Find local users by their username """ + # TODO: This doesn't seem to be used anyway def find_local_by_username(username) do actors = Repo.all( from( a in Actor, where: - (ilike(a.preferred_username, ^like_sanitize(username)) or - ilike(a.name, ^like_sanitize(username))) and is_nil(a.domain) + fragment( + "f_unaccent(?) <% f_unaccent(?) or + f_unaccent(coalesce(?, '')) <% f_unaccent(?)", + a.preferred_username, + ^username, + a.name, + ^username + ), + where: is_nil(a.domain), + order_by: + fragment( + "word_similarity(?, ?) + word_similarity(coalesce(?, ''), ?) desc", + a.preferred_username, + ^username, + a.name, + ^username + ) ) ) @@ -526,48 +547,31 @@ defmodule Mobilizon.Actors do from( a in Actor, where: - ilike(a.preferred_username, ^like_sanitize(username)) or - ilike(a.name, ^like_sanitize(username)) + fragment( + "f_unaccent(?) %> f_unaccent(?) or + f_unaccent(coalesce(?, '')) %> f_unaccent(?)", + a.preferred_username, + ^username, + a.name, + ^username + ), + order_by: + fragment( + "word_similarity(?, ?) + word_similarity(coalesce(?, ''), ?) desc", + a.preferred_username, + ^username, + a.name, + ^username + ) ) |> paginate(page, limit) ) end - # Sanitize the LIKE queries - defp like_sanitize(value) do - "%" <> String.replace(value, ~r/([\\%_])/, "\\1") <> "%" - end - - @email_regex ~r/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/ - @spec search(String.t()) :: {:ok, list(Actor.t())} | {:ok, []} | {:error, any()} - def search(name) do - # find already saved accounts - case find_actors_by_username_or_name(name) do - [] -> - # no accounts found, let's test if it's an username@domain.tld - with true <- Regex.match?(@email_regex, name), - # creating the actor in that case - {:ok, actor} <- ActivityPub.find_or_make_actor_from_nickname(name) do - {:ok, [actor]} - else - false -> - {:ok, []} - - # error fingering the actor - {:error, err} -> - {:error, err} - end - - actors = [_ | _] -> - # actors already saved found ! - {:ok, actors} - end - end - @doc """ - Find a group by its actor id + Get a group by its actor id """ - def find_group_by_actor_id(actor_id) do + def get_group_by_actor_id(actor_id) do case Repo.get_by(Actor, id: actor_id, type: :Group) do nil -> {:error, :group_not_found} actor -> {:ok, actor} diff --git a/lib/mobilizon/actors/user.ex b/lib/mobilizon/actors/user.ex index 780581698..ad6441bd9 100644 --- a/lib/mobilizon/actors/user.ex +++ b/lib/mobilizon/actors/user.ex @@ -45,7 +45,7 @@ defmodule Mobilizon.Actors.User do :password, min: 6, max: 100, - message: "The choosen password is too short." + message: "The chosen password is too short." ) if Map.has_key?(attrs, :default_actor) do diff --git a/lib/mobilizon/events/events.ex b/lib/mobilizon/events/events.ex index ae81caf7a..0e50ee4ee 100644 --- a/lib/mobilizon/events/events.ex +++ b/lib/mobilizon/events/events.ex @@ -248,7 +248,19 @@ defmodule Mobilizon.Events do query = from(e in Event, - where: e.visibility == ^:public and ilike(e.title, ^like_sanitize(name)), + where: + e.visibility == ^:public and + fragment( + "f_unaccent(?) %> f_unaccent(?)", + e.title, + ^name + ), + order_by: + fragment( + "word_similarity(?, ?) desc", + e.title, + ^name + ), preload: [:organizer_actor] ) |> paginate(page, limit) @@ -256,11 +268,6 @@ defmodule Mobilizon.Events do Repo.all(query) end - # Sanitize the LIKE queries - defp like_sanitize(value) do - "%" <> String.replace(value, ~r/([\\%_])/, "\\1") <> "%" - end - @doc """ Creates a event. diff --git a/lib/mobilizon_web/api/search.ex b/lib/mobilizon_web/api/search.ex new file mode 100644 index 000000000..067d30764 --- /dev/null +++ b/lib/mobilizon_web/api/search.ex @@ -0,0 +1,99 @@ +defmodule MobilizonWeb.API.Search do + alias Mobilizon.Service.ActivityPub + alias Mobilizon.Actors + alias Mobilizon.Actors.Actor + alias Mobilizon.Events + alias Mobilizon.Events.{Event, Comment} + + require Logger + + @doc """ + Search + """ + @spec search(String.t(), integer(), integer()) :: + {:ok, list(Actor.t())} | {:ok, []} | {:error, any()} + def search(search, page \\ 1, limit \\ 10) do + do_search(search, page, limit, %{events: true, actors: true}) + end + + @doc """ + Not used at the moment + """ + # TODO: Use me + @spec search_actors(String.t(), integer(), integer()) :: + {:ok, list(Actor.t())} | {:ok, []} | {:error, any()} + def search_actors(search, page \\ 1, limit \\ 10) do + do_search(search, page, limit, %{actors: true}) + end + + @doc """ + Not used at the moment + """ + # TODO: Use me + @spec search_events(String.t(), integer(), integer()) :: + {:ok, list(Event.t())} | {:ok, []} | {:error, any()} + def search_events(search, page \\ 1, limit \\ 10) do + do_search(search, page, limit, %{events: true}) + end + + # Do the actual search + @spec do_search(String.t(), integer(), integer(), map()) :: {:ok, list(any())} + defp do_search(search, page, limit, opts) do + search = String.trim(search) + + cond do + search == "" -> + {:error, "Search can't be empty"} + + String.match?(search, ~r/@/) -> + {:ok, process_from_username(search)} + + String.starts_with?(search, "https://") -> + {:ok, process_from_url(search)} + + String.starts_with?(search, "http://") -> + {:ok, process_from_url(search)} + + true -> + events = + Task.async(fn -> + if Map.get(opts, :events, false), + do: Events.find_events_by_name(search, page, limit), + else: [] + end) + + actors = + Task.async(fn -> + if Map.get(opts, :actors, false), + do: Actors.find_actors_by_username_or_name(search, page, limit), + else: [] + end) + + {:ok, Task.await(events) ++ Task.await(actors)} + end + end + + # If the search string is an username + @spec process_from_username(String.t()) :: Actor.t() | nil + defp process_from_username(search) do + with {:ok, actor} <- ActivityPub.find_or_make_actor_from_nickname(search) do + actor + else + {:error, _err} -> + Logger.debug("Unable to find or make actor '#{search}'") + nil + end + end + + # If the search string is an URL + @spec process_from_url(String.t()) :: Actor.t() | Event.t() | Comment.t() | nil + defp process_from_url(search) do + with {:ok, object} <- ActivityPub.fetch_object_from_url(search) do + object + else + {:error, _err} -> + Logger.debug("Unable to find or make object from URL '#{search}'") + nil + end + end +end diff --git a/lib/mobilizon_web/resolvers/event.ex b/lib/mobilizon_web/resolvers/event.ex index 74938c19d..b9e964afa 100644 --- a/lib/mobilizon_web/resolvers/event.ex +++ b/lib/mobilizon_web/resolvers/event.ex @@ -2,7 +2,6 @@ defmodule MobilizonWeb.Resolvers.Event do @moduledoc """ Handles the event-related GraphQL calls """ - alias Mobilizon.Service.ActivityPub alias Mobilizon.Activity alias Mobilizon.Events.{Event, Participant} alias Mobilizon.Actors.User @@ -122,39 +121,6 @@ defmodule MobilizonWeb.Resolvers.Event do {:error, "You need to be logged-in to leave an event"} end - @doc """ - Search events by title - """ - def search_events(_parent, %{search: search, page: page, limit: limit}, _resolution) do - {:ok, Mobilizon.Events.find_events_by_name(search, page, limit)} - end - - @doc """ - Search events and actors by title - """ - def search_events_and_actors(_parent, %{search: search, page: page, limit: limit}, _resolution) do - search = String.trim(search) - - found = - case String.contains?(search, "@") do - true -> - with {:ok, actor} <- ActivityPub.find_or_make_actor_from_nickname(search) do - actor - else - {:error, _err} -> - nil - end - - _ -> - Mobilizon.Events.find_events_by_name(search, page, limit) ++ - Mobilizon.Actors.find_actors_by_username_or_name(search, page, limit) - end - - require Logger - Logger.debug(inspect(found)) - {:ok, found} - end - @doc """ Create an event """ diff --git a/lib/mobilizon_web/resolvers/group.ex b/lib/mobilizon_web/resolvers/group.ex index 5a698e2eb..3cc92d8b4 100644 --- a/lib/mobilizon_web/resolvers/group.ex +++ b/lib/mobilizon_web/resolvers/group.ex @@ -81,7 +81,7 @@ defmodule MobilizonWeb.Resolvers.Group do } } ) do - with {:ok, %Actor{} = group} <- Actors.find_group_by_actor_id(group_id), + with {:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id), {:is_owned, true, _} <- User.owns_actor(user, actor_id), {:ok, %Member{} = member} <- Member.get_member(actor_id, group.id), {:is_admin, true} <- Member.is_administrator(member), diff --git a/lib/mobilizon_web/resolvers/search.ex b/lib/mobilizon_web/resolvers/search.ex new file mode 100644 index 000000000..adc4b8125 --- /dev/null +++ b/lib/mobilizon_web/resolvers/search.ex @@ -0,0 +1,13 @@ +defmodule MobilizonWeb.Resolvers.Search do + @moduledoc """ + Handles the event-related GraphQL calls + """ + alias MobilizonWeb.API.Search + + @doc """ + Search events and actors by title + """ + def search_events_and_actors(_parent, %{search: search, page: page, limit: limit}, _resolution) do + Search.search(search, page, limit) + end +end diff --git a/lib/mobilizon_web/schema.ex b/lib/mobilizon_web/schema.ex index 8ddc79e34..fe8d31f7a 100644 --- a/lib/mobilizon_web/schema.ex +++ b/lib/mobilizon_web/schema.ex @@ -122,7 +122,7 @@ defmodule MobilizonWeb.Schema do arg(:search, non_null(:string)) arg(:page, :integer, default_value: 1) arg(:limit, :integer, default_value: 10) - resolve(&Resolvers.Event.search_events_and_actors/3) + resolve(&Resolvers.Search.search_events_and_actors/3) end import_fields(:user_queries) diff --git a/lib/service/activity_pub/activity_pub.ex b/lib/service/activity_pub/activity_pub.ex index d94701cb4..015f11c70 100644 --- a/lib/service/activity_pub/activity_pub.ex +++ b/lib/service/activity_pub/activity_pub.ex @@ -66,6 +66,7 @@ defmodule Mobilizon.Service.ActivityPub do @doc """ Fetch an object from an URL, from our local database of events and comments, then eventually remote """ + # TODO: Make database calls parallel @spec fetch_object_from_url(String.t()) :: {:ok, %Event{}} | {:ok, %Comment{}} | {:error, any()} def fetch_object_from_url(url) do Logger.info("Fetching object from url #{url}") @@ -73,6 +74,7 @@ defmodule Mobilizon.Service.ActivityPub do with true <- String.starts_with?(url, "http"), nil <- Events.get_event_by_url(url), nil <- Events.get_comment_from_url(url), + {:error, :actor_not_found} <- Actors.get_actor_by_url(url), {:ok, %{body: body, status_code: code}} when code in 200..299 <- HTTPoison.get( url, @@ -97,12 +99,16 @@ defmodule Mobilizon.Service.ActivityPub do "Note" -> {:ok, Events.get_comment_full_from_url!(activity.data["object"]["id"])} + "Actor" -> + {:ok, Actors.get_actor_by_url!(activity.data["object"]["id"], true)} + other -> {:error, other} end else %Event{url: event_url} -> {:ok, Events.get_event_by_url!(event_url)} %Comment{url: comment_url} -> {:ok, Events.get_comment_full_from_url!(comment_url)} + %Actor{url: actor_url} -> {:ok, Actors.get_actor_by_url!(actor_url, true)} e -> {:error, e} end end diff --git a/mix.exs b/mix.exs index 8fe67fe95..ae77b6a84 100644 --- a/mix.exs +++ b/mix.exs @@ -94,7 +94,8 @@ defmodule Mobilizon.Mixfile do {:ex_unit_notifier, "~> 0.1", only: :test}, {:dialyxir, "~> 1.0.0-rc.4", only: [:dev], runtime: false}, {:exvcr, "~> 0.10", only: :test}, - {:credo, "~> 1.0.0", only: [:dev, :test], runtime: false} + {:credo, "~> 1.0.0", only: [:dev, :test], runtime: false}, + {:mock, "~> 0.3.0", only: :test} ] end diff --git a/mix.lock b/mix.lock index 230d1557f..8e9a80ecb 100644 --- a/mix.lock +++ b/mix.lock @@ -64,6 +64,7 @@ "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"}, "mix_test_watch": {:hex, :mix_test_watch, "0.9.0", "c72132a6071261893518fa08e121e911c9358713f62794a90c95db59042af375", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm"}, "mmdb2_decoder": {:hex, :mmdb2_decoder, "0.3.0", "03a159a52342d3328cf2b774f1036e56719f7edc7f919180588a7764854c3318", [:mix], [], "hexpm"}, + "mock": {:hex, :mock, "0.3.3", "42a433794b1291a9cf1525c6d26b38e039e0d3a360732b5e467bfc77ef26c914", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"}, "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm"}, "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, "phoenix": {:hex, :phoenix, "1.4.1", "801f9d632808657f1f7c657c8bbe624caaf2ba91429123ebe3801598aea4c3d9", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm"}, diff --git a/priv/repo/migrations/20180109150000_prerequites.exs b/priv/repo/migrations/20180109150000_prerequites.exs index 0cc58b153..73cdfd614 100644 --- a/priv/repo/migrations/20180109150000_prerequites.exs +++ b/priv/repo/migrations/20180109150000_prerequites.exs @@ -2,6 +2,15 @@ defmodule Mobilizon.Repo.Migrations.Prerequites do use Ecto.Migration def up do + IO.puts("\n + ######################################################### + # If the CREATE EXTENSION or DROP EXTENSION calls fail, # + # please manually execute them with an authorized # + # PostgreSQL user with SUPER USER role. # + ######################################################### + \n + ") + execute(""" CREATE TYPE datetimetz AS ( dt timestamptz, @@ -10,10 +19,23 @@ defmodule Mobilizon.Repo.Migrations.Prerequites do """) execute("CREATE EXTENSION IF NOT EXISTS postgis") + execute("CREATE EXTENSION IF NOT EXISTS pg_trgm") + execute("CREATE EXTENSION IF NOT EXISTS unaccent") end def down do + IO.puts("\n + ######################################################### + # If the CREATE EXTENSION or DROP EXTENSION calls fail, # + # please manually execute them with an authorized # + # PostgreSQL user with SUPER USER role. # + ######################################################### + \n + ") + execute("DROP TYPE IF EXISTS datetimetz;") execute("DROP EXTENSION IF EXISTS postgis") + execute("DROP EXTENSION IF EXISTS pg_trgm") + execute("DROP EXTENSION IF EXISTS unaccent") end end diff --git a/priv/repo/migrations/20190219164310_create_search_indexes.exs b/priv/repo/migrations/20190219164310_create_search_indexes.exs new file mode 100644 index 000000000..ee602ed2f --- /dev/null +++ b/priv/repo/migrations/20190219164310_create_search_indexes.exs @@ -0,0 +1,48 @@ +defmodule Mobilizon.Repo.Migrations.CreateSearchIndexes do + use Ecto.Migration + + def change do + IO.puts("\n + ######################################################### + # If the CREATE EXTENSION or DROP EXTENSION calls fail, # + # please manually execute them with an authorized # + # PostgreSQL user with SUPER USER role. # + ######################################################### + \n + ") + + try do + execute("CREATE EXTENSION IF NOT EXISTS pg_trgm", "DROP EXTENSION IF EXISTS pg_trgm") + execute("CREATE EXTENSION IF NOT EXISTS unaccent", "DROP EXTENSION IF EXISTS unaccent") + + execute( + "CREATE OR REPLACE FUNCTION public.f_unaccent(text) + RETURNS text AS +$func$ +SELECT public.unaccent('public.unaccent', $1) +$func$ LANGUAGE sql IMMUTABLE;", + "DROP FUNCTION IF EXISTS public.f_unaccent" + ) + + execute( + "CREATE INDEX \"event_title_trigram\" ON \"events\" USING GIN (f_unaccent(title) gin_trgm_ops)", + "DROP INDEX IF EXISTS event_title_trigram" + ) + + execute( + "CREATE INDEX \"actor_preferred_username_trigram\" ON \"actors\" + USING GIN (f_unaccent(preferred_username) gin_trgm_ops)", + "DROP INDEX IF EXISTS actor_preferred_username_trigram" + ) + + execute( + "CREATE INDEX \"actor_name_trigram\" ON \"actors\" + USING GIN (f_unaccent(name) gin_trgm_ops)", + "DROP INDEX IF EXISTS actor_name_trigram" + ) + rescue + e in Postgrex.Error -> + IO.puts(e.message) + end + end +end diff --git a/test/mobilizon/actors/actors_test.exs b/test/mobilizon/actors/actors_test.exs index aa15715bf..a9c400c25 100644 --- a/test/mobilizon/actors/actors_test.exs +++ b/test/mobilizon/actors/actors_test.exs @@ -44,7 +44,7 @@ defmodule Mobilizon.ActorsTest do setup do user = insert(:user) - actor = insert(:actor, user: user) + actor = insert(:actor, user: user, preferred_username: "tcit") {:ok, actor: actor} end @@ -177,11 +177,10 @@ defmodule Mobilizon.ActorsTest do test "test find_local_by_username/1 returns local actors with similar usernames", %{ actor: actor } do - actor2 = insert(:actor) - [%Actor{id: actor_found_id} | tail] = Actors.find_local_by_username("thomas") + actor2 = insert(:actor, preferred_username: "tcit") + [%Actor{id: actor_found_id} | tail] = Actors.find_local_by_username("tcit") %Actor{id: actor2_found_id} = hd(tail) - assert actor_found_id == actor.id - assert actor2_found_id == actor2.id + assert MapSet.new([actor_found_id, actor2_found_id]) == MapSet.new([actor.id, actor2.id]) end test "test find_actors_by_username_or_name/1 returns actors with similar usernames", %{ @@ -189,7 +188,7 @@ defmodule Mobilizon.ActorsTest do } do use_cassette "actors/remote_actor_mastodon_tcit" do with {:ok, %Actor{id: actor2_id}} <- Actors.get_or_fetch_by_url(@remote_account_url) do - actors_ids = Actors.find_actors_by_username_or_name("t") |> Enum.map(& &1.id) + actors_ids = Actors.find_actors_by_username_or_name("tcit") |> Enum.map(& &1.id) assert MapSet.new(actors_ids) == MapSet.new([actor2_id, actor_id]) end end @@ -200,26 +199,6 @@ defmodule Mobilizon.ActorsTest do assert actors == [] end - test "test search/1 returns accounts for search with existing accounts", %{actor: actor} do - with {:ok, [%Actor{id: actor_found_id}]} <- Actors.search("t") do - assert actor_found_id == actor.id - end - end - - test "test search/1 returns accounts for search with non existent accounts" do - assert {:ok, []} == Actors.search("nonexistent") - end - - test "test search/1 returns accounts for search with existing remote accounts" do - with {:ok, [%Actor{preferred_username: username}]} <- Actors.search("tcit@framapiaf.org") do - assert username == "tcit" - end - end - - test "test search/1 returns accounts for search with non existent remote accounts" do - assert {:error, "No ActivityPub URL found in WebFinger"} == Actors.search("tcit@yolo.tld") - end - test "test get_public_key_for_url/1 with local actor", %{actor: actor} do assert Actor.get_public_key_for_url(actor.url) == actor.keys |> Mobilizon.Actors.Actor.prepare_public_key() diff --git a/test/mobilizon/events/events_test.exs b/test/mobilizon/events/events_test.exs index 0ecd530f0..ddf92e29d 100644 --- a/test/mobilizon/events/events_test.exs +++ b/test/mobilizon/events/events_test.exs @@ -59,12 +59,12 @@ defmodule Mobilizon.EventsTest do assert title == hd(Events.find_events_by_name(event.title)).title %Event{title: title2} = event2 = insert(:event, title: "Special event") - assert event2.title == hd(Events.find_events_by_name("Special")).title + assert event2.title == Events.find_events_by_name("Special") |> hd() |> Map.get(:title) - assert event2.title == hd(Events.find_events_by_name(" Special ")).title + assert event2.title == Events.find_events_by_name(" Special ") |> hd() |> Map.get(:title) - assert title == hd(Events.find_events_by_name("")).title - assert title2 == hd(tl(Events.find_events_by_name(""))).title + assert title == Events.find_events_by_name("") |> hd() |> Map.get(:title) + assert title2 == Events.find_events_by_name("") |> tl |> hd() |> Map.get(:title) end test "find_close_events/3 returns events in the area" do diff --git a/test/mobilizon_web/api/search_test.exs b/test/mobilizon_web/api/search_test.exs new file mode 100644 index 000000000..d26933ddb --- /dev/null +++ b/test/mobilizon_web/api/search_test.exs @@ -0,0 +1,55 @@ +defmodule MobilizonWeb.API.SearchTest do + use ExUnit.Case, async: false + + alias Mobilizon.Events + alias Mobilizon.Events.Event + alias Mobilizon.Actors + alias Mobilizon.Actors.Actor + alias Mobilizon.Service.ActivityPub + alias MobilizonWeb.API.Search + + import Mock + + test "search an user by username" do + with_mock ActivityPub, + find_or_make_actor_from_nickname: fn "toto@domain.tld" -> {:ok, %Actor{id: 1}} end do + assert {:ok, %Actor{id: 1}} == Search.search("toto@domain.tld") + assert_called(ActivityPub.find_or_make_actor_from_nickname("toto@domain.tld")) + end + end + + test "search something by URL" do + with_mock ActivityPub, + fetch_object_from_url: fn "https://social.tcit.fr/users/tcit" -> {:ok, %Actor{id: 1}} end do + assert {:ok, %Actor{id: 1}} == Search.search("https://social.tcit.fr/users/tcit") + assert_called(ActivityPub.fetch_object_from_url("https://social.tcit.fr/users/tcit")) + end + end + + test "search everything" do + with_mocks([ + {Actors, [], [find_actors_by_username_or_name: fn "toto", 1, 10 -> [%Actor{}] end]}, + {Events, [], [find_events_by_name: fn "toto", 1, 10 -> [%Event{}] end]} + ]) do + assert {:ok, [%Event{}, %Actor{}]} = Search.search("toto") + assert_called(Actors.find_actors_by_username_or_name("toto", 1, 10)) + assert_called(Events.find_events_by_name("toto", 1, 10)) + end + end + + test "search actors" do + with_mock Actors, + find_actors_by_username_or_name: fn "toto", 1, 10 -> [%Actor{}] end do + assert {:ok, [%Actor{}]} = Search.search_actors("toto") + assert_called(Actors.find_actors_by_username_or_name("toto", 1, 10)) + end + end + + test "search events" do + with_mock Events, + find_events_by_name: fn "toto", 1, 10 -> [%Event{}] end do + assert {:ok, [%Event{}]} = Search.search_events("toto") + assert_called(Events.find_events_by_name("toto", 1, 10)) + end + end +end diff --git a/test/mobilizon_web/resolvers/event_resolver_test.exs b/test/mobilizon_web/resolvers/event_resolver_test.exs index d43b1fca2..8759086d0 100644 --- a/test/mobilizon_web/resolvers/event_resolver_test.exs +++ b/test/mobilizon_web/resolvers/event_resolver_test.exs @@ -88,35 +88,6 @@ defmodule MobilizonWeb.Resolvers.EventResolverTest do assert json_response(res, 200)["data"]["createEvent"]["title"] == "come to my event" end - test "search_events_and_actors/3 finds events and actors", %{conn: conn, actor: actor} do - event = insert(:event, title: "test") - - query = """ - { - search(search: "test") { - ...on Event { - title, - uuid, - __typename - }, - ...on Actor { - preferredUsername, - __typename - } - } - } - """ - - res = - conn - |> get("/api", AbsintheHelpers.query_skeleton(query, "search")) - - assert hd(json_response(res, 200)["data"]["search"])["uuid"] == to_string(event.uuid) - - assert hd(tl(json_response(res, 200)["data"]["search"]))["preferredUsername"] == - actor.preferred_username - end - test "list_events/3 returns events", context do event = insert(:event) diff --git a/test/mobilizon_web/resolvers/search_resolver_test.exs b/test/mobilizon_web/resolvers/search_resolver_test.exs new file mode 100644 index 000000000..7774ec4ed --- /dev/null +++ b/test/mobilizon_web/resolvers/search_resolver_test.exs @@ -0,0 +1,109 @@ +defmodule MobilizonWeb.Resolvers.SearchResolverTest do + use MobilizonWeb.ConnCase + alias MobilizonWeb.AbsintheHelpers + import Mobilizon.Factory + + setup %{conn: conn} do + user = insert(:user) + + {:ok, conn: conn, user: user} + end + + test "search_events_and_actors/3 finds events and actors with basic search", %{ + conn: conn, + user: user + } do + actor = insert(:actor, user: user, preferred_username: "test") + event = insert(:event, title: "test") + + query = """ + { + search(search: "test") { + ...on Event { + title, + uuid, + __typename + }, + ...on Actor { + preferredUsername, + __typename + } + } + } + """ + + res = + conn + |> get("/api", AbsintheHelpers.query_skeleton(query, "search")) + + assert hd(json_response(res, 200)["data"]["search"])["uuid"] == to_string(event.uuid) + + assert hd(tl(json_response(res, 200)["data"]["search"]))["preferredUsername"] == + actor.preferred_username + end + + test "search_events_and_actors/3 finds events and actors with word search", %{ + conn: conn, + user: user + } do + actor = insert(:actor, user: user, preferred_username: "toto", name: "I like pineapples") + event = insert(:event, title: "Pineapple fashion week") + + # Elaborate query + query = """ + { + search(search: "pineapple") { + ...on Event { + title, + uuid, + __typename + }, + ...on Actor { + preferredUsername, + __typename + } + } + } + """ + + res = + conn + |> get("/api", AbsintheHelpers.query_skeleton(query, "search")) + + assert hd(json_response(res, 200)["data"]["search"])["uuid"] == to_string(event.uuid) + + assert hd(tl(json_response(res, 200)["data"]["search"]))["preferredUsername"] == + actor.preferred_username + end + + test "search_events_and_actors/3 finds events and actors with accented search", %{ + conn: conn, + user: user + } do + insert(:actor, user: user, preferred_username: "toto", name: "Torréfaction") + event = insert(:event, title: "Tour du monde des cafés") + + # Elaborate query + query = """ + { + search(search: "café") { + ...on Event { + title, + uuid, + __typename + }, + ...on Actor { + preferredUsername, + __typename + } + } + } + """ + + res = + conn + |> get("/api", AbsintheHelpers.query_skeleton(query, "search")) + + assert hd(json_response(res, 200)["data"]["search"])["uuid"] == to_string(event.uuid) + end +end