fix: D1-1 correct media translation unique_constraint name so duplicate-language violations return a changeset error

This commit is contained in:
2026-05-29 21:13:18 +02:00
parent 84b91750fb
commit 91b0ffe4c5
3 changed files with 55 additions and 2 deletions

View File

@@ -113,7 +113,7 @@ All reconciled to follow code. Specs must be self-consistent and match code.
| ID | Claim | Spec | Path | | ID | Claim | Spec | Path |
|---|---|---|---| |---|---|---|---|
| D1-1 | UniqueMediaTranslation invariant | media.allium:108 | Write test: create duplicate media translation, expect rejection | | D1-1 | ~~UniqueMediaTranslation invariant~~ | media.allium:108 | **Resolved:** test added (re-upsert updates not duplicates; direct duplicate insert rejected). Test exposed a real bug — `Media.Translation` declared the migration's index name but ecto_sqlite3 derives the violated-constraint name from columns, so violations crashed instead of returning a changeset error; fixed `unique_constraint` name to `:media_translations_translation_for_language_index` |
| D1-2 | UniqueTranslationPerLanguage invariant | translation.allium:94 | Write test: create duplicate post translation, expect rejection | | D1-2 | UniqueTranslationPerLanguage invariant | translation.allium:94 | Write test: create duplicate post translation, expect rejection |
| D1-3 | BundledDefaultTemplatesExistOutsideProjectData | template.allium:65 | Write test: render with no Template rows, bundled template found | | D1-3 | BundledDefaultTemplatesExistOutsideProjectData | template.allium:65 | Write test: render with no Template rows, bundled template found |
| D1-4 | UserTemplateDirectoryOverridesBundledDefaults | template.allium:75 | Write test: project template overrides bundled same-slug | | D1-4 | UserTemplateDirectoryOverridesBundledDefaults | template.allium:75 | Write test: project template overrides bundled same-slug |

View File

@@ -62,6 +62,6 @@ defmodule BDS.Media.Translation do
:updated_at :updated_at
]) ])
|> foreign_key_constraint(:translation_for) |> foreign_key_constraint(:translation_for)
|> unique_constraint(:language, name: :media_translations_translation_language_idx) |> unique_constraint(:language, name: :media_translations_translation_for_language_index)
end end
end end

View File

@@ -524,6 +524,59 @@ defmodule BDS.MediaTest do
assert contents =~ "caption: \"Bildunterschrift\"\n---" assert contents =~ "caption: \"Bildunterschrift\"\n---"
end end
test "UniqueMediaTranslation: a media has at most one translation per language", %{
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, first} =
BDS.Media.upsert_media_translation(media.id, "de", %{title: "Titel"})
# Re-upserting the same language updates the existing row instead of adding a second.
assert {:ok, second} =
BDS.Media.upsert_media_translation(media.id, "de", %{title: "Geändert"})
assert second.id == first.id
assert [persisted] =
BDS.Media.Translation
|> where([t], t.translation_for == ^media.id and t.language == "de")
|> Repo.all()
assert persisted.title == "Geändert"
# A direct insert of a distinct row with the same (media, language) is rejected by the
# unique constraint backing the invariant.
now = BDS.Persistence.now_ms()
duplicate =
BDS.Media.Translation.changeset(%BDS.Media.Translation{}, %{
id: Ecto.UUID.generate(),
project_id: media.project_id,
translation_for: media.id,
language: "de",
title: "Duplicate",
created_at: now,
updated_at: now
})
assert {:error, changeset} = Repo.insert(duplicate)
assert %{language: ["has already been taken"]} = errors_on(changeset)
end
defp errors_on(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
end)
end)
end
defp tiny_jpeg_binary do defp tiny_jpeg_binary do
Image.new!(3, 2, color: [255, 0, 0]) Image.new!(3, 2, color: [255, 0, 0])
|> Image.write!(:memory, suffix: ".jpg", quality: 85) |> Image.write!(:memory, suffix: ".jpg", quality: 85)