feat: PLAN step 2 done

This commit is contained in:
2026-04-25 22:06:34 +02:00
parent 2991edf4cf
commit 2b1aca4143
5 changed files with 477 additions and 3 deletions

View File

@@ -1,6 +1,8 @@
defmodule BDS.MediaTest do
use ExUnit.Case, async: false
import Ecto.Query
alias BDS.Repo
setup do
@@ -117,6 +119,49 @@ defmodule BDS.MediaTest do
end)
end
test "deleting a post rewrites linked media sidecars to remove that post id", %{
project: project,
temp_dir: temp_dir
} do
source_path = Path.join(temp_dir, "sample.txt")
File.write!(source_path, "hello media")
assert {:ok, media} =
BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
assert {:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Linked Post",
content: "Body"
})
now = BDS.Persistence.now_ms()
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: now
}
])
assert {:ok, _updated_media} = BDS.Media.update_media(media.id, %{})
sidecar_before = File.read!(Path.join(temp_dir, media.sidecar_path))
assert sidecar_before =~ "linkedPostIds: [\"#{post.id}\"]\n"
assert {:ok, :deleted} = BDS.Posts.delete_post(post.id)
refute Repo.exists?(from row in "post_media", where: field(row, :media_id) == ^media.id)
sidecar_after = File.read!(Path.join(temp_dir, media.sidecar_path))
assert sidecar_after =~ "linkedPostIds: []\n"
end
test "rebuild_media_from_files recreates media rows from sidecars", %{
project: project,
temp_dir: temp_dir

View File

@@ -1,17 +1,62 @@
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)
on_exit(fn -> File.rm_rf(temp_dir) end)
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}
@@ -75,11 +120,119 @@ defmodule BDS.PostTranslationsTest do
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} =
@@ -145,4 +298,84 @@ defmodule BDS.PostTranslationsTest do
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