From 7fec63688ce8dc8a8bb5dda254add2e88f8db795 Mon Sep 17 00:00:00 2001 From: FloatingGhost Date: Mon, 29 Aug 2022 17:48:58 +0100 Subject: [PATCH] add tests for translation, API docs --- config/description.exs | 43 ++++++++++- lib/pleroma/akkoma/translators/deepl.ex | 58 ++++++++++++++ .../akkoma/translators/libre_translate.ex | 51 +++++++++++++ .../{ => akkoma}/translators/translator.ex | 2 +- lib/pleroma/translators/deepl.ex | 45 ----------- lib/pleroma/translators/libre_translate.ex | 40 ---------- .../controllers/status_controller.ex | 8 +- test/pleroma/translators/deepl_test.exs | 75 +++++++++++++++++++ .../translators/libre_translate_text.exs | 73 ++++++++++++++++++ .../controllers/status_controller_test.exs | 66 +++++++++++++--- 10 files changed, 356 insertions(+), 105 deletions(-) create mode 100644 lib/pleroma/akkoma/translators/deepl.ex create mode 100644 lib/pleroma/akkoma/translators/libre_translate.ex rename lib/pleroma/{ => akkoma}/translators/translator.ex (72%) delete mode 100644 lib/pleroma/translators/deepl.ex delete mode 100644 lib/pleroma/translators/libre_translate.ex create mode 100644 test/pleroma/translators/deepl_test.exs create mode 100644 test/pleroma/translators/libre_translate_text.exs diff --git a/config/description.exs b/config/description.exs index 47f80532d..019bca485 100644 --- a/config/description.exs +++ b/config/description.exs @@ -3352,9 +3352,9 @@ config :pleroma, :config_description, [ }, %{ group: :pleroma, - key: :deepl, + key: :translator, type: :group, - description: "DeepL settings.", + description: "Translation Settings", children: [ %{ key: :enabled, @@ -3362,16 +3362,51 @@ config :pleroma, :config_description, [ description: "Is translation enabled?", suggestion: [true, false] }, + %{ + key: :module, + type: :module, + description: "Translation module.", + suggestions: {:list_behaviour_implementations, Pleroma.Akkoma.Translator} + } + ] + }, + %{ + group: :pleroma, + key: :deepl, + label: "DeepL", + type: :group, + description: "DeepL Settings.", + children: [ %{ key: :tier, - type: :atom, + type: {:dropdown, :atom}, description: "API Tier", - suggestion: [:free, :pro] + suggestions: [:free, :pro] }, %{ key: :api_key, type: :string, description: "API key for DeepL", + suggestions: [nil] + } + ] + }, + %{ + group: :pleroma, + key: :libre_translate, + type: :group, + description: "LibreTranslate Settings.", + children: [ + %{ + key: :url, + type: :string, + description: "URL for libretranslate", + suggestion: [nil] + }, + %{ + key: :api_key, + type: :string, + description: "API key for libretranslate", suggestion: [nil] } ] diff --git a/lib/pleroma/akkoma/translators/deepl.ex b/lib/pleroma/akkoma/translators/deepl.ex new file mode 100644 index 000000000..b782e7cd6 --- /dev/null +++ b/lib/pleroma/akkoma/translators/deepl.ex @@ -0,0 +1,58 @@ +defmodule Pleroma.Akkoma.Translators.DeepL do + @behaviour Pleroma.Akkoma.Translator + + alias Pleroma.HTTP + alias Pleroma.Config + require Logger + + defp base_url(:free) do + "https://api-free.deepl.com/v2/" + end + + defp base_url(:pro) do + "https://api.deepl.com/v2/" + end + + defp api_key do + Config.get([:deepl, :api_key]) + end + + defp tier do + Config.get([:deepl, :tier]) + end + + @impl Pleroma.Akkoma.Translator + def translate(string, to_language) do + with {:ok, %{status: 200} = response} <- do_request(api_key(), tier(), string, to_language), + {:ok, body} <- Jason.decode(response.body) do + %{"translations" => [%{"text" => translated, "detected_source_language" => detected}]} = + body + + {:ok, detected, translated} + else + {:ok, %{status: 403} = response} -> + Logger.warning("DeepL: Request rejected, please check your API key: #{inspect(response)}") + {:error, "DeepL request failed"} + + {:error, reason} -> + {:error, reason} + end + end + + defp do_request(api_key, tier, string, to_language) do + HTTP.post( + base_url(tier) <> "translate", + URI.encode_query( + %{ + text: string, + target_lang: to_language + }, + :rfc3986 + ), + [ + {"authorization", "DeepL-Auth-Key #{api_key}"}, + {"content-type", "application/x-www-form-urlencoded"} + ] + ) + end +end diff --git a/lib/pleroma/akkoma/translators/libre_translate.ex b/lib/pleroma/akkoma/translators/libre_translate.ex new file mode 100644 index 000000000..86ce0036b --- /dev/null +++ b/lib/pleroma/akkoma/translators/libre_translate.ex @@ -0,0 +1,51 @@ +defmodule Pleroma.Akkoma.Translators.LibreTranslate do + @behaviour Pleroma.Akkoma.Translator + + alias Pleroma.Config + alias Pleroma.HTTP + require Logger + + defp api_key do + Config.get([:libre_translate, :api_key]) + end + + defp url do + Config.get([:libre_translate, :url]) + end + + @impl Pleroma.Akkoma.Translator + def translate(string, to_language) do + with {:ok, %{status: 200} = response} <- do_request(string, to_language), + {:ok, body} <- Jason.decode(response.body) do + %{"translatedText" => translated, "detectedLanguage" => %{"language" => detected}} = body + + {:ok, detected, translated} + else + {:ok, %{status: 403} = response} -> + Logger.warning("DeepL: Request rejected, please check your API key: #{inspect(response)}") + {:error, "libre_translate: request failed"} + + {:error, reason} -> + {:error, reason} + end + end + + defp do_request(string, to_language) do + url = URI.parse(url()) + url = %{url | path: "/translate"} + + HTTP.post( + to_string(url), + Jason.encode!(%{ + q: string, + source: "auto", + target: to_language, + format: "html", + api_key: api_key() + }), + [ + {"content-type", "application/json"} + ] + ) + end +end diff --git a/lib/pleroma/translators/translator.ex b/lib/pleroma/akkoma/translators/translator.ex similarity index 72% rename from lib/pleroma/translators/translator.ex rename to lib/pleroma/akkoma/translators/translator.ex index e18f921b5..0276ed6c2 100644 --- a/lib/pleroma/translators/translator.ex +++ b/lib/pleroma/akkoma/translators/translator.ex @@ -1,3 +1,3 @@ -defmodule Akkoma.Translator do +defmodule Pleroma.Akkoma.Translator do @callback translate(String.t(), String.t()) :: {:ok, String.t(), String.t()} | {:error, any()} end diff --git a/lib/pleroma/translators/deepl.ex b/lib/pleroma/translators/deepl.ex deleted file mode 100644 index 830ad1555..000000000 --- a/lib/pleroma/translators/deepl.ex +++ /dev/null @@ -1,45 +0,0 @@ -defmodule Akkoma.Translators.DeepL do - @behaviour Akkoma.Translator - - use Tesla - alias Pleroma.Config - - plug(Tesla.Middleware.EncodeFormUrlencoded) - plug(Tesla.Middleware.DecodeJson) - - defp base_url(:free) do - "https://api-free.deepl.com/v2/" - end - - defp base_url(:pro) do - "https://api.deepl.com/v2/" - end - - defp api_key do - Config.get([:deepl, :api_key]) - end - - defp tier do - Config.get([:deepl, :tier]) - end - - @impl Akkoma.Translator - def translate(string, to_language) do - with {:ok, response} <- do_request(api_key(), tier(), string, to_language) do - %{"translations" => [%{"text" => translated, "detected_source_language" => detected}]} = - response.body - - {:ok, detected, translated} - else - {:error, reason} -> {:error, reason} - end - end - - defp do_request(api_key, tier, string, to_language) do - post(base_url(tier) <> "translate", %{ - auth_key: api_key, - text: string, - target_lang: to_language - }) - end -end diff --git a/lib/pleroma/translators/libre_translate.ex b/lib/pleroma/translators/libre_translate.ex deleted file mode 100644 index e8b741741..000000000 --- a/lib/pleroma/translators/libre_translate.ex +++ /dev/null @@ -1,40 +0,0 @@ -defmodule Akkoma.Translators.LibreTranslate do - @behaviour Akkoma.Translator - - use Tesla - alias Pleroma.Config - - plug(Tesla.Middleware.JSON) - - defp api_key do - Config.get([:libre_translate, :api_key]) - end - - defp url do - Config.get([:libre_translate, :url]) - end - - @impl Akkoma.Translator - def translate(string, to_language) do - with {:ok, response} <- do_request(string, to_language) do - %{"translatedText" => translated, "detectedLanguage" => %{"language" => detected}} = - response.body - - {:ok, detected, translated} - else - {:error, reason} -> {:error, reason} - end - end - - defp do_request(string, to_language) do - url = URI.parse(url()) - url = %{url | path: "/translate"} - - post(url, %{ - q: string, - source: "auto", - target: to_language, - api_key: api_key() - }) - end -end diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 1e24593ab..43fb63c38 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -428,18 +428,18 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do translation_module <- Config.get([:translator, :module]), {:ok, detected, translation} <- translation_module.translate(activity.object.data["content"], language) do - json(conn, %{detected_lanugage: detected, text: translation}) + json(conn, %{detected_language: detected, text: translation}) else {:enabled, false} -> conn |> put_status(:bad_request) - |> json(%{"error" => "DeepL is not enabled"}) + |> json(%{"error" => "Translation is not enabled"}) {:visible, false} -> {:error, :not_found} - _e -> - {:error, :internal_server_error} + e -> + e end end diff --git a/test/pleroma/translators/deepl_test.exs b/test/pleroma/translators/deepl_test.exs new file mode 100644 index 000000000..3e1f1e175 --- /dev/null +++ b/test/pleroma/translators/deepl_test.exs @@ -0,0 +1,75 @@ +defmodule Pleroma.Akkoma.Translators.DeepLTest do + use Pleroma.DataCase, async: true + + alias Pleroma.Akkoma.Translators.DeepL + + describe "translating with deepl" do + setup do + clear_config([:deepl, :api_key], "deepl_api_key") + end + + test "should work with the free tier" do + clear_config([:deepl, :tier], :free) + + Tesla.Mock.mock(fn + %{method: :post, url: "https://api-free.deepl.com/v2/translate"} = env -> + auth_header = Enum.find(env.headers, fn {k, _v} -> k == "authorization" end) + assert {"authorization", "DeepL-Auth-Key deepl_api_key"} = auth_header + + %Tesla.Env{ + status: 200, + body: + Jason.encode!(%{ + translations: [ + %{ + "text" => "I will crush you", + "detected_source_language" => "ja" + } + ] + }) + } + end) + + assert {:ok, "ja", "I will crush you"} = DeepL.translate("ギュギュ握りつぶしちゃうぞ", "en") + end + + test "should work with the pro tier" do + clear_config([:deepl, :tier], :pro) + + Tesla.Mock.mock(fn + %{method: :post, url: "https://api.deepl.com/v2/translate"} = env -> + auth_header = Enum.find(env.headers, fn {k, _v} -> k == "authorization" end) + assert {"authorization", "DeepL-Auth-Key deepl_api_key"} = auth_header + + %Tesla.Env{ + status: 200, + body: + Jason.encode!(%{ + translations: [ + %{ + "text" => "I will crush you", + "detected_source_language" => "ja" + } + ] + }) + } + end) + + assert {:ok, "ja", "I will crush you"} = DeepL.translate("ギュギュ握りつぶしちゃうぞ", "en") + end + + test "should gracefully fail if the API errors" do + clear_config([:deepl, :tier], :free) + + Tesla.Mock.mock(fn + %{method: :post, url: "https://api-free.deepl.com/v2/translate"} -> + %Tesla.Env{ + status: 403, + body: "" + } + end) + + assert {:error, "DeepL request failed"} = DeepL.translate("ギュギュ握りつぶしちゃうぞ", "en") + end + end +end diff --git a/test/pleroma/translators/libre_translate_text.exs b/test/pleroma/translators/libre_translate_text.exs new file mode 100644 index 000000000..798b45113 --- /dev/null +++ b/test/pleroma/translators/libre_translate_text.exs @@ -0,0 +1,73 @@ +defmodule Pleroma.Akkoma.Translators.LibreTranslateTest do + use Pleroma.DataCase, async: true + + alias Pleroma.Akkoma.Translators.LibreTranslate + + describe "translating with libre translate" do + setup do + clear_config([:libre_translate, :url], "http://libre.translate/translate") + end + + test "should work without an API key" do + Tesla.Mock.mock(fn + %{method: :post, url: "http://libre.translate/translate"} = env -> + assert {:ok, %{"api_key" => nil}} = Jason.decode(env.body) + + %Tesla.Env{ + status: 200, + body: + Jason.encode!(%{ + detectedLanguage: %{ + confidence: 83, + language: "ja" + }, + translatedText: "I will crush you" + }) + } + end) + + assert {:ok, "ja", "I will crush you"} = LibreTranslate.translate("ギュギュ握りつぶしちゃうぞ", "en") + end + + test "should work with an API key" do + clear_config([:libre_translate, :api_key], "libre_translate_api_key") + + Tesla.Mock.mock(fn + %{method: :post, url: "http://libre.translate/translate"} = env -> + assert {:ok, %{"api_key" => "libre_translate_api_key"}} = Jason.decode(env.body) + + %Tesla.Env{ + status: 200, + body: + Jason.encode!(%{ + detectedLanguage: %{ + confidence: 83, + language: "ja" + }, + translatedText: "I will crush you" + }) + } + end) + + assert {:ok, "ja", "I will crush you"} = LibreTranslate.translate("ギュギュ握りつぶしちゃうぞ", "en") + end + + test "should gracefully handle API key errors" do + clear_config([:libre_translate, :api_key], "") + + Tesla.Mock.mock(fn + %{method: :post, url: "http://libre.translate/translate"} -> + %Tesla.Env{ + status: 403, + body: + Jason.encode!(%{ + error: "Please contact the server operator to obtain an API key" + }) + } + end) + + assert {:error, "libre_translate: request failed"} = + LibreTranslate.translate("ギュギュ握りつぶしちゃうぞ", "en") + end + end +end diff --git a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs index 405105aca..771164be3 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -2073,30 +2073,74 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do end describe "translating statuses" do - setup do: oauth_access(["read:statuses"]) + setup do + clear_config([:translator, :enabled], true) + clear_config([:translator, :module], Pleroma.Akkoma.Translators.DeepL) + clear_config([:deepl, :api_key], "deepl_api_key") + oauth_access(["read:statuses"]) + end + + test "should return text and detected language", %{conn: conn} do + clear_config([:deepl, :tier], :free) - test "translating a status with deepl", %{conn: conn} do Tesla.Mock.mock(fn - %{method: :post, url: "http://api-free.deepl.com/translate"} -> - {:ok, - %{ - status: 200, - body: - ~s({"data": {"translations": [{"translatedText": "Tell me, for whom do you fight?"}]}}) - }} + %{method: :post, url: "https://api-free.deepl.com/v2/translate"} -> + %Tesla.Env{ + status: 200, + body: + Jason.encode!(%{ + translations: [ + %{ + "text" => "Tell me, for whom do you fight?", + "detected_source_language" => "ja" + } + ] + }) + } end) user = insert(:user) - {:ok, quoted_status} = CommonAPI.post(user, %{status: "何のために闘う?"}) + {:ok, to_translate} = CommonAPI.post(user, %{status: "何のために闘う?"}) conn = conn |> put_req_header("content-type", "application/json") - |> get("/api/v1/statuses/#{quoted_status.id}/translations/en") + |> get("/api/v1/statuses/#{to_translate.id}/translations/en") response = json_response_and_validate_schema(conn, 200) assert response["text"] == "Tell me, for whom do you fight?" + assert response["detected_language"] == "ja" + end + + test "should not allow translating of statuses you cannot see", %{conn: conn} do + clear_config([:deepl, :tier], :free) + + Tesla.Mock.mock(fn + %{method: :post, url: "https://api-free.deepl.com/v2/translate"} -> + %Tesla.Env{ + status: 200, + body: + Jason.encode!(%{ + translations: [ + %{ + "text" => "Tell me, for whom do you fight?", + "detected_source_language" => "ja" + } + ] + }) + } + end) + + user = insert(:user) + {:ok, to_translate} = CommonAPI.post(user, %{status: "何のために闘う?", visibility: "private"}) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> get("/api/v1/statuses/#{to_translate.id}/translations/en") + + json_response_and_validate_schema(conn, 404) end end end