382 lines
12 KiB
Elixir
382 lines
12 KiB
Elixir
defmodule BDS.PostTranslationsTest do
|
|
use ExUnit.Case, async: false
|
|
|
|
import Ecto.Query
|
|
|
|
alias BDS.AI
|
|
alias BDS.Media
|
|
alias BDS.Metadata
|
|
alias BDS.Posts
|
|
alias BDS.Repo
|
|
|
|
defmodule FakeRuntime do
|
|
def generate(endpoint, request, opts) do
|
|
test_pid = Keyword.fetch!(opts, :test_pid)
|
|
send(test_pid, {:runtime_request, endpoint, request})
|
|
|
|
case request.operation do
|
|
:translate_post ->
|
|
{:ok,
|
|
%{
|
|
json: %{
|
|
"title" => "Hallo Welt",
|
|
"excerpt" => "Kurze Zusammenfassung",
|
|
"content" => "# Hallo Welt\n\nUbersetzter Inhalt"
|
|
},
|
|
usage: %{input_tokens: 22, output_tokens: 14, cache_read_tokens: 0, cache_write_tokens: 0}
|
|
}}
|
|
|
|
:translate_media ->
|
|
{:ok,
|
|
%{
|
|
json: %{
|
|
"title" => "Medientitel",
|
|
"alt" => "Medien Alt",
|
|
"caption" => "Medien Beschriftung"
|
|
},
|
|
usage: %{input_tokens: 12, output_tokens: 10, cache_read_tokens: 0, cache_write_tokens: 0}
|
|
}}
|
|
end
|
|
end
|
|
end
|
|
|
|
setup do
|
|
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
|
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
|
|
:ok = BDS.Tasks.clear_finished()
|
|
|
|
temp_dir =
|
|
Path.join(System.tmp_dir!(), "bds-post-translations-#{System.unique_integer([:positive])}")
|
|
|
|
File.mkdir_p!(temp_dir)
|
|
|
|
original_posts_config = Application.get_env(:bds, :posts, [])
|
|
|
|
on_exit(fn ->
|
|
File.rm_rf(temp_dir)
|
|
Application.put_env(:bds, :posts, original_posts_config)
|
|
_ = BDS.Tasks.clear_finished()
|
|
end)
|
|
|
|
{:ok, project} = BDS.Projects.create_project(%{name: "Translations", data_path: temp_dir})
|
|
%{project: project, temp_dir: temp_dir}
|
|
end
|
|
|
|
test "upserted post translations publish with the canonical post, reopen on edit, and delete their file",
|
|
%{project: project, temp_dir: temp_dir} do
|
|
assert {:ok, post} =
|
|
Posts.create_post(%{
|
|
project_id: project.id,
|
|
title: "Canonical Post",
|
|
excerpt: "English summary",
|
|
content: "Hello world",
|
|
language: "en"
|
|
})
|
|
|
|
assert {:ok, translation} =
|
|
Posts.upsert_post_translation(post.id, "de", %{
|
|
title: "Kanonischer Beitrag",
|
|
excerpt: "Deutsche Zusammenfassung",
|
|
content: "Hallo Welt"
|
|
})
|
|
|
|
assert translation.translation_for == post.id
|
|
assert translation.language == "de"
|
|
assert translation.status == :draft
|
|
assert translation.file_path == ""
|
|
assert translation.content == "Hallo Welt"
|
|
|
|
assert {:ok, [listed_translation]} = Posts.list_post_translations(post.id)
|
|
assert listed_translation.id == translation.id
|
|
|
|
assert {:ok, _published_post} = Posts.publish_post(post.id)
|
|
assert published_translation = Posts.get_post_translation!(translation.id)
|
|
|
|
assert published_translation.status == :published
|
|
assert is_integer(published_translation.published_at)
|
|
assert published_translation.content == nil
|
|
assert published_translation.file_path =~ ~r/^posts\/\d{4}\/\d{2}\/canonical-post\.de\.md$/
|
|
|
|
translation_path = Path.join(temp_dir, published_translation.file_path)
|
|
assert File.exists?(translation_path)
|
|
|
|
translation_contents = File.read!(translation_path)
|
|
assert translation_contents =~ "translationFor: #{post.id}\n"
|
|
assert translation_contents =~ "title: Kanonischer Beitrag\n"
|
|
assert translation_contents =~ "language: de\n"
|
|
assert translation_contents =~ "status: published\n"
|
|
assert translation_contents =~ "\n---\nHallo Welt\n"
|
|
|
|
assert {:ok, reopened_translation} =
|
|
Posts.upsert_post_translation(post.id, "de", %{
|
|
title: "Neu formuliert",
|
|
excerpt: "Aktualisiert",
|
|
content: "Neuer Entwurf"
|
|
})
|
|
|
|
assert reopened_translation.status == :draft
|
|
assert reopened_translation.title == "Neu formuliert"
|
|
assert reopened_translation.excerpt == "Aktualisiert"
|
|
assert reopened_translation.content == "Neuer Entwurf"
|
|
assert reopened_translation.updated_at >= published_translation.updated_at
|
|
|
|
reopened_source = Posts.get_post!(post.id)
|
|
assert reopened_source.status == :draft
|
|
assert reopened_source.content == "Hello world"
|
|
assert reopened_source.file_path ==
|
|
String.replace_suffix(published_translation.file_path, ".de.md", ".md")
|
|
|
|
assert {:ok, :deleted} = Posts.delete_post_translation(reopened_translation.id)
|
|
refute File.exists?(translation_path)
|
|
assert {:ok, []} = Posts.list_post_translations(post.id)
|
|
end
|
|
|
|
test "create_post enqueues and completes auto-translation tasks for missing project languages",
|
|
%{project: project} do
|
|
configure_auto_translation_test_runtime()
|
|
|
|
assert {:ok, _metadata} =
|
|
Metadata.update_project_metadata(project.id, %{
|
|
main_language: "en",
|
|
blog_languages: ["en", "de", "fr"]
|
|
})
|
|
|
|
assert {:ok, post} =
|
|
Posts.create_post(%{
|
|
project_id: project.id,
|
|
title: "Hello World",
|
|
excerpt: "Short summary",
|
|
content: "# Hello\n\nSource body",
|
|
language: "en"
|
|
})
|
|
|
|
translations = wait_for_post_translations(post.id, ["de", "fr"])
|
|
|
|
assert Enum.map(translations, & &1.language) == ["de", "fr"]
|
|
assert Enum.all?(translations, &(&1.title == "Hallo Welt"))
|
|
assert Enum.all?(translations, &(&1.excerpt == "Kurze Zusammenfassung"))
|
|
assert Enum.all?(translations, &(&1.content == "# Hallo Welt\n\nUbersetzter Inhalt"))
|
|
|
|
tasks = wait_for_ai_tasks(2)
|
|
|
|
assert Enum.any?(tasks, &(&1.name == "Auto-translate Post to de" and &1.status == :completed))
|
|
assert Enum.any?(tasks, &(&1.name == "Auto-translate Post to fr" and &1.status == :completed))
|
|
assert Enum.all?(tasks, &(&1.group_name == "AI"))
|
|
end
|
|
|
|
test "update_post auto-translates missing languages and cascades linked media translations",
|
|
%{project: project, temp_dir: temp_dir} do
|
|
configure_auto_translation_test_runtime()
|
|
|
|
assert {:ok, _metadata} =
|
|
Metadata.update_project_metadata(project.id, %{
|
|
main_language: "en",
|
|
blog_languages: ["en", "de"]
|
|
})
|
|
|
|
assert {:ok, post} =
|
|
Posts.create_post(%{
|
|
project_id: project.id,
|
|
title: "Needs Translation",
|
|
excerpt: "Draft excerpt",
|
|
content: "Source body",
|
|
language: "en",
|
|
do_not_translate: true
|
|
})
|
|
|
|
source_path = Path.join(temp_dir, "linked-media.txt")
|
|
File.write!(source_path, "linked media")
|
|
|
|
assert {:ok, media} =
|
|
Media.import_media(%{
|
|
project_id: project.id,
|
|
source_path: source_path,
|
|
title: "Source Media",
|
|
alt: "Source Alt",
|
|
caption: "Source Caption",
|
|
language: "en"
|
|
})
|
|
|
|
Repo.insert_all("post_media", [
|
|
%{
|
|
id: Ecto.UUID.generate(),
|
|
project_id: project.id,
|
|
post_id: post.id,
|
|
media_id: media.id,
|
|
sort_order: 0,
|
|
created_at: BDS.Persistence.now_ms()
|
|
}
|
|
])
|
|
|
|
assert {:ok, _updated_post} =
|
|
Posts.update_post(post.id, %{
|
|
do_not_translate: false,
|
|
content: "Updated source body"
|
|
})
|
|
|
|
[translation] = wait_for_post_translations(post.id, ["de"])
|
|
assert translation.language == "de"
|
|
|
|
media_translation = wait_for_media_translation(media.id, "de")
|
|
assert media_translation.title == "Medientitel"
|
|
assert media_translation.alt == "Medien Alt"
|
|
assert media_translation.caption == "Medien Beschriftung"
|
|
|
|
tasks = wait_for_ai_tasks(2)
|
|
|
|
assert Enum.any?(tasks, &(&1.name == "Auto-translate Post to de" and &1.status == :completed))
|
|
|
|
assert Enum.any?(tasks, fn task ->
|
|
task.name == "Auto-translate Media to de" and task.status == :completed
|
|
end)
|
|
|
|
assert File.exists?(Path.join(temp_dir, media.file_path <> ".de.meta"))
|
|
end
|
|
|
|
test "validate_translations reports missing languages, orphan translation files, and do-not-translate posts",
|
|
%{project: project, temp_dir: temp_dir} do
|
|
assert {:ok, _metadata} =
|
|
Metadata.update_project_metadata(project.id, %{
|
|
main_language: "en",
|
|
blog_languages: ["en", "de", "fr"]
|
|
})
|
|
|
|
assert {:ok, translatable_post} =
|
|
Posts.create_post(%{
|
|
project_id: project.id,
|
|
title: "Needs French",
|
|
content: "Body",
|
|
language: "en"
|
|
})
|
|
|
|
assert {:ok, _translation} =
|
|
Posts.upsert_post_translation(translatable_post.id, "de", %{
|
|
title: "Braucht Französisch",
|
|
content: "Inhalt"
|
|
})
|
|
|
|
assert {:ok, _published} = Posts.publish_post(translatable_post.id)
|
|
|
|
assert {:ok, ignored_post} =
|
|
Posts.create_post(%{
|
|
project_id: project.id,
|
|
title: "Skip Me",
|
|
content: "Body",
|
|
language: "en"
|
|
})
|
|
|
|
assert {:ok, ignored_post} = Posts.update_post(ignored_post.id, %{do_not_translate: true})
|
|
assert {:ok, _published_ignored_post} = Posts.publish_post(ignored_post.id)
|
|
|
|
orphan_dir = Path.join([temp_dir, "posts", "2026", "04"])
|
|
File.mkdir_p!(orphan_dir)
|
|
|
|
orphan_path = Path.join(orphan_dir, "orphan.fr.md")
|
|
|
|
File.write!(
|
|
orphan_path,
|
|
[
|
|
"---",
|
|
"id: orphan-translation",
|
|
"translationFor: missing-post",
|
|
"language: fr",
|
|
"title: Orpheline",
|
|
"status: published",
|
|
"createdAt: 1711843200",
|
|
"updatedAt: 1711929600",
|
|
"publishedAt: 1712016000",
|
|
"---",
|
|
"Texte orphelin",
|
|
""
|
|
]
|
|
|> Enum.join("\n")
|
|
)
|
|
|
|
assert {:ok, report} = Posts.validate_translations(project.id)
|
|
|
|
assert report.missing == [%{post_id: translatable_post.id, language: "fr"}]
|
|
assert report.orphan_files == ["posts/2026/04/orphan.fr.md"]
|
|
assert report.do_not_translate_posts == [ignored_post.id]
|
|
end
|
|
|
|
defp configure_auto_translation_test_runtime do
|
|
assert {:ok, _endpoint} =
|
|
AI.put_endpoint(
|
|
:online,
|
|
%{
|
|
url: "https://api.example.test/v1",
|
|
api_key: "online-secret",
|
|
model: "gpt-4o-mini"
|
|
}
|
|
)
|
|
|
|
assert :ok = AI.set_airplane_mode(false)
|
|
assert :ok = AI.put_model_preference(:title, "gpt-4.1-mini")
|
|
|
|
Application.put_env(:bds, :posts,
|
|
auto_translation_ai_opts: [
|
|
runtime: FakeRuntime,
|
|
test_pid: self()
|
|
]
|
|
)
|
|
end
|
|
|
|
defp wait_for_post_translations(post_id, languages, attempts \\ 100)
|
|
|
|
defp wait_for_post_translations(_post_id, _languages, 0) do
|
|
flunk("post translations did not reach expected state")
|
|
end
|
|
|
|
defp wait_for_post_translations(post_id, languages, attempts) do
|
|
{:ok, translations} = Posts.list_post_translations(post_id)
|
|
expected = Enum.sort(languages)
|
|
|
|
if Enum.sort(Enum.map(translations, & &1.language)) == expected do
|
|
translations
|
|
else
|
|
Process.sleep(20)
|
|
wait_for_post_translations(post_id, languages, attempts - 1)
|
|
end
|
|
end
|
|
|
|
defp wait_for_media_translation(media_id, language, attempts \\ 100)
|
|
|
|
defp wait_for_media_translation(_media_id, _language, 0) do
|
|
flunk("media translation did not reach expected state")
|
|
end
|
|
|
|
defp wait_for_media_translation(media_id, language, attempts) do
|
|
translation =
|
|
Repo.one(
|
|
from translation in BDS.Media.Translation,
|
|
where: translation.translation_for == ^media_id and translation.language == ^language
|
|
)
|
|
|
|
if translation do
|
|
translation
|
|
else
|
|
Process.sleep(20)
|
|
wait_for_media_translation(media_id, language, attempts - 1)
|
|
end
|
|
end
|
|
|
|
defp wait_for_ai_tasks(count, attempts \\ 100)
|
|
|
|
defp wait_for_ai_tasks(_count, 0) do
|
|
flunk("AI tasks did not reach expected state")
|
|
end
|
|
|
|
defp wait_for_ai_tasks(count, attempts) do
|
|
tasks =
|
|
BDS.Tasks.list_tasks()
|
|
|> Enum.filter(&(&1.group_name == "AI"))
|
|
|
|
if length(tasks) >= count and Enum.all?(tasks, &(&1.status == :completed)) do
|
|
tasks
|
|
else
|
|
Process.sleep(20)
|
|
wait_for_ai_tasks(count, attempts - 1)
|
|
end
|
|
end
|
|
end
|