From 91b0ffe4c59d95b560d4c1c69f36091caab59aba Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Fri, 29 May 2026 21:13:18 +0200 Subject: [PATCH] fix: D1-1 correct media translation unique_constraint name so duplicate-language violations return a changeset error --- SPECGAPS.md | 2 +- lib/bds/media/translation.ex | 2 +- test/bds/media_test.exs | 53 ++++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/SPECGAPS.md b/SPECGAPS.md index 6202fe7..52568bd 100644 --- a/SPECGAPS.md +++ b/SPECGAPS.md @@ -113,7 +113,7 @@ All reconciled to follow code. Specs must be self-consistent and match code. | 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-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 | diff --git a/lib/bds/media/translation.ex b/lib/bds/media/translation.ex index de71850..28c34a5 100644 --- a/lib/bds/media/translation.ex +++ b/lib/bds/media/translation.ex @@ -62,6 +62,6 @@ defmodule BDS.Media.Translation do :updated_at ]) |> 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 diff --git a/test/bds/media_test.exs b/test/bds/media_test.exs index a08c3fc..cbac589 100644 --- a/test/bds/media_test.exs +++ b/test/bds/media_test.exs @@ -524,6 +524,59 @@ defmodule BDS.MediaTest do assert contents =~ "caption: \"Bildunterschrift\"\n---" 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 Image.new!(3, 2, color: [255, 0, 0]) |> Image.write!(:memory, suffix: ".jpg", quality: 85)