Merge branch 'main' into issue/mitgliederverwaltung-420
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing

Integrate current main (CSV import, GDPR join-form description, dependency and
tooling bumps) into the bulk-actions-dropdown feature. Gettext catalogs were
reconciled with mix gettext.extract --merge; the CHANGELOG Unreleased entries
of both sides were combined.
This commit is contained in:
Simon 2026-06-04 16:56:27 +02:00
commit 6a6099659b
48 changed files with 3541 additions and 148 deletions

View file

@ -0,0 +1,104 @@
defmodule MvWeb.ImportTemplateControllerTest do
use MvWeb.ConnCase, async: true
setup %{conn: conn} do
actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, custom_field} =
Mv.Membership.CustomField
|> Ash.Changeset.for_create(:create, %{name: "Lieblingsfarbe", value_type: :string})
|> Ash.create(actor: actor)
%{conn: conn, custom_field: custom_field}
end
describe "authenticated EN template" do
setup %{conn: conn} do
admin = Mv.Fixtures.user_with_role_fixture("admin")
%{conn: MvWeb.ConnCase.conn_with_password_user(conn, admin)}
end
test "returns CSV with English headers and current custom fields", %{conn: conn} do
conn = get(conn, ~p"/admin/import/template/en")
assert response_content_type(conn, :csv) =~ "text/csv"
body = response(conn, 200)
header = body |> String.split("\n") |> List.first()
assert header =~ "email"
# EN headers use the canonical English variant from HeaderMapper, not the
# underscore form, so the template stays faithful to the documented variant list.
assert header =~ "first name"
assert header =~ "last name"
refute header =~ "first_name"
assert header =~ "house number"
refute header =~ "house_number"
assert header =~ "Lieblingsfarbe"
assert get_resp_header(conn, "content-disposition")
|> Enum.any?(&(&1 =~ "member_import_en.csv"))
end
test "neutralizes formula-injection in a custom field header", %{conn: conn} do
actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, _} =
Mv.Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
name: "=cmd|'/c calc'!A1",
value_type: :string
})
|> Ash.create(actor: actor)
conn = get(conn, ~p"/admin/import/template/en")
body = response(conn, 200)
header = body |> String.split("\n") |> List.first()
# The dangerous cell must be prefixed with a single quote so spreadsheet
# software does not evaluate it as a formula, matching the export writer.
refute header =~ ~r/(^|;)=cmd/
assert header =~ "'=cmd|'/c calc'!A1"
end
end
describe "authenticated DE template" do
setup %{conn: conn} do
admin = Mv.Fixtures.user_with_role_fixture("admin")
%{conn: MvWeb.ConnCase.conn_with_password_user(conn, admin)}
end
test "returns CSV with German headers and current custom fields", %{conn: conn} do
conn = get(conn, ~p"/admin/import/template/de")
body = response(conn, 200)
header = body |> String.split("\n") |> List.first()
assert header =~ "E-Mail"
assert header =~ "Vorname"
assert header =~ "Lieblingsfarbe"
assert get_resp_header(conn, "content-disposition")
|> Enum.any?(&(&1 =~ "member_import_de.csv"))
end
end
describe "authorization" do
@tag role: :unauthenticated
test "unauthenticated request does not receive a CSV", %{conn: conn} do
conn = get(conn, ~p"/admin/import/template/en")
refute conn.status == 200
refute get_resp_header(conn, "content-type") |> Enum.any?(&(&1 =~ "text/csv"))
refute to_string(conn.resp_body) =~ "email"
end
@tag role: :member
test "user without import permission is forbidden", %{conn: conn} do
conn = get(conn, ~p"/admin/import/template/en")
refute conn.status == 200
refute get_resp_header(conn, "content-type") |> Enum.any?(&(&1 =~ "text/csv"))
refute to_string(conn.resp_body) =~ "email"
end
end
end

View file

@ -0,0 +1,85 @@
defmodule MvWeb.Helpers.JoinDescriptionRendererTest do
@moduledoc """
Tests for the join-description renderer that auto-links raw URLs and Markdown
links while escaping all other content.
"""
use ExUnit.Case, async: true
use ExUnitProperties
alias MvWeb.Helpers.JoinDescriptionRenderer
defp html(value) do
value
|> JoinDescriptionRenderer.render()
|> Phoenix.HTML.safe_to_string()
end
describe "render/1" do
test "converts a raw URL to an anchor tag with the standard link class" do
result = html("Akzeptiere https://example.com/dsgvo")
assert result =~ ~s(<a href="https://example.com/dsgvo" class="link link-primary")
assert result =~ "https://example.com/dsgvo</a>"
assert result =~ "Akzeptiere "
end
test "converts Markdown [text](url) to an anchor tag with the standard link class" do
result = html("[Datenschutzerklärung](https://example.com/dsgvo)")
assert result =~ ~s(<a href="https://example.com/dsgvo" class="link link-primary")
assert result =~ ">Datenschutzerklärung</a>"
end
test "returns an empty safe string for nil input" do
assert JoinDescriptionRenderer.render(nil) == {:safe, ""}
end
test "escapes arbitrary HTML in non-link text" do
result = html("<script>alert(1)</script>")
refute result =~ "<script>"
assert result =~ "&lt;script&gt;"
end
test "does not double-link a Markdown link whose URL also looks like a raw URL" do
result = html("[Datenschutz](https://example.com/x)")
# exactly one anchor, no nested anchor for the inner raw URL
assert result |> :binary.matches("<a ") |> length() == 1
end
end
describe "property: link-free text" do
property "preserves non-link text content as HTML-escaped output" do
check all(text <- link_free_string()) do
result = html(text)
# No links emitted, and text content equals the HTML-escaped input.
refute result =~ "<a "
assert result == Phoenix.HTML.html_escape(text) |> Phoenix.HTML.safe_to_string()
end
end
end
describe "property: well-formed Markdown links" do
property "renders every [text](https://...) as a single anchor with verbatim text and url" do
check all(
label <- string(:alphanumeric, min_length: 1),
path <- string(:alphanumeric)
) do
url = "https://example.com/#{path}"
result = html("[#{label}](#{url})")
assert result =~ ~s(<a href="#{url}" class="link link-primary">#{label}</a>)
assert result |> :binary.matches("<a ") |> length() == 1
end
end
end
# Printable strings that contain no bare URLs and no Markdown-link opening bracket.
defp link_free_string do
:printable
|> string()
|> filter(fn s -> not String.contains?(s, "http") and not String.contains?(s, "[") end)
end
end

View file

@ -0,0 +1,102 @@
defmodule MvWeb.CustomFieldLive.FormTest do
@moduledoc """
Tests for the CustomFieldLive.FormComponent join_description input.
Covers that an admin can set and persist a custom field's join_description via
the settings edit form.
"""
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.CustomField
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
admin_role = Mv.Fixtures.role_fixture("admin")
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create(actor: system_actor)
{:ok, user} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update(actor: system_actor)
user_with_role = Ash.load!(user, :role, domain: Mv.Accounts, actor: system_actor)
conn = log_in_user(build_conn(), user_with_role)
session = conn.private[:plug_session] || %{}
conn = Plug.Test.init_test_session(conn, Map.put(session, "locale", "en"))
%{conn: conn, actor: system_actor}
end
defp log_in_user(conn, user) do
conn
|> Phoenix.ConnTest.init_test_session(%{})
|> AshAuthentication.Plug.Helpers.store_in_session(user)
end
defp open_edit_form(view, custom_field) do
view
|> element("tr#custom_fields-#{custom_field.id} td", custom_field.name)
|> render_click()
end
describe "join_description input" do
test "form shows a join_description input", %{conn: conn, actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{name: "dsgvo_field", value_type: :boolean})
|> Ash.create(actor: actor)
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
open_edit_form(view, custom_field)
assert has_element?(view, "input[name='custom_field[join_description]']")
end
test "form shows an info tooltip explaining allowed link syntax", %{conn: conn, actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{name: "dsgvo_field", value_type: :boolean})
|> Ash.create(actor: actor)
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
open_edit_form(view, custom_field)
assert has_element?(
view,
"[data-testid='join-description-link-hint'] .hero-information-circle"
)
end
test "form accepts and persists join_description", %{conn: conn, actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{name: "dsgvo_field", value_type: :boolean})
|> Ash.create(actor: actor)
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
open_edit_form(view, custom_field)
view
|> form("#custom-field-form-#{custom_field.id}-form", %{
"custom_field" => %{
"name" => custom_field.name,
"join_description" => "Accept the GDPR at https://example.com/dsgvo"
}
})
|> render_submit()
updated = Ash.get!(CustomField, custom_field.id, actor: actor)
assert updated.join_description == "Accept the GDPR at https://example.com/dsgvo"
end
end
end

View file

@ -0,0 +1,31 @@
defmodule MvWeb.ImportLive.ComponentsTest do
use ExUnit.Case, async: true
alias MvWeb.ImportLive.Components
describe "import_button_disabled?/2" do
@done_entry %{done?: true}
test "disables the Start Import button while the preview is displayed" do
# During :preview the upload entry is done, but re-clicking Start Import
# would re-run the upload processing and overwrite the current preview.
assert Components.import_button_disabled?(:preview, [@done_entry]) == true
end
test "disables the button while an import is running" do
assert Components.import_button_disabled?(:running, [@done_entry]) == true
end
test "disables the button when there are no upload entries" do
assert Components.import_button_disabled?(:idle, []) == true
end
test "disables the button while an upload entry is not yet done" do
assert Components.import_button_disabled?(:idle, [%{done?: false}]) == true
end
test "enables the button at idle with a completed upload" do
assert Components.import_button_disabled?(:idle, [@done_entry]) == false
end
end
end

View file

@ -27,6 +27,16 @@ defmodule MvWeb.ImportLiveTest do
defp submit_import(view), do: view |> form("#csv-upload-form", %{}) |> render_submit()
defp confirm_import(view),
do: view |> element("[data-testid='confirm-import-button']") |> render_click()
# Full flow: upload, enter preview (start), then confirm to begin processing.
defp run_full_import(view, csv_content, filename \\ "test_import.csv") do
upload_csv_file(view, csv_content, filename)
submit_import(view)
confirm_import(view)
end
defp wait_for_import_completion, do: Process.sleep(1000)
# ---------- Business logic: Authorization ----------
@ -56,8 +66,7 @@ defmodule MvWeb.ImportLiveTest do
|> File.read!()
{:ok, view, _html} = live(conn, ~p"/admin/import")
upload_csv_file(view, csv_content)
submit_import(view)
run_full_import(view, csv_content)
wait_for_import_completion()
assert has_element?(view, "[data-testid='import-results-panel']")
@ -121,8 +130,7 @@ defmodule MvWeb.ImportLiveTest do
invalid_csv: csv_content
} do
{:ok, view, _html} = live(conn, ~p"/admin/import")
upload_csv_file(view, csv_content, "invalid_import.csv")
submit_import(view)
run_full_import(view, csv_content, "invalid_import.csv")
wait_for_import_completion()
assert has_element?(view, "[data-testid='import-results-panel']")
@ -141,8 +149,7 @@ defmodule MvWeb.ImportLiveTest do
invalid_rows =
for i <- 1..100, do: "Row#{i};Last#{i};;Country#{i};City#{i};Street#{i};12345\n"
upload_csv_file(view, header <> Enum.join(invalid_rows), "large_invalid.csv")
submit_import(view)
run_full_import(view, header <> Enum.join(invalid_rows), "large_invalid.csv")
wait_for_import_completion()
assert has_element?(view, "[data-testid='import-results-panel']")
@ -174,8 +181,7 @@ defmodule MvWeb.ImportLiveTest do
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_bom_semicolon.csv"])
|> File.read!()
upload_csv_file(view, csv_content, "bom_import.csv")
submit_import(view)
run_full_import(view, csv_content, "bom_import.csv")
wait_for_import_completion()
assert has_element?(view, "[data-testid='import-results-panel']")
@ -193,8 +199,7 @@ defmodule MvWeb.ImportLiveTest do
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_empty_lines.csv"])
|> File.read!()
upload_csv_file(view, csv_content, "empty_lines.csv")
submit_import(view)
run_full_import(view, csv_content, "empty_lines.csv")
wait_for_import_completion()
assert has_element?(view, "[data-testid='import-error-list']")
@ -208,8 +213,7 @@ defmodule MvWeb.ImportLiveTest do
unknown_custom_field_csv: csv_content
} do
{:ok, view, _html} = live(conn, ~p"/admin/import")
upload_csv_file(view, csv_content, "unknown_custom.csv")
submit_import(view)
run_full_import(view, csv_content, "unknown_custom.csv")
wait_for_import_completion()
assert has_element?(view, "[data-testid='import-results-panel']")
@ -240,23 +244,41 @@ defmodule MvWeb.ImportLiveTest do
assert has_element?(view, "[data-testid='start-import-button']")
end
test "template links and file input are present", %{conn: conn} do
test "template links point to the dynamic import template routes", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/import")
assert has_element?(view, "a[href='/admin/import/template/en']")
assert has_element?(view, "a[href='/admin/import/template/de']")
refute has_element?(view, "a[href*='/templates/member_import_en.csv']")
end
test "file input is present", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/import")
assert has_element?(view, "a[href*='/templates/member_import_en.csv']")
assert has_element?(view, "a[href*='/templates/member_import_de.csv']")
assert has_element?(view, "label[for='csv_file']")
assert has_element?(view, "#csv_file_help")
assert has_element?(view, "[data-testid='csv-upload-form'] input[type='file']")
end
test "custom fields notice lists accepted groups and fee-type column names", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/import")
# Groups column variants (both EN and DE)
assert html =~ "Groups"
assert html =~ "Gruppen"
# Fee type column variants (both EN and DE)
assert html =~ "Beitragsart"
assert html =~ "Fee Type"
assert html =~ "fee type"
# Fee status is always ignored (named explicitly)
assert html =~ "Bezahlstatus"
end
test "after successful import, progress container has aria-live", %{conn: conn} do
csv_content =
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|> File.read!()
{:ok, view, _html} = live(conn, ~p"/admin/import")
upload_csv_file(view, csv_content)
submit_import(view)
run_full_import(view, csv_content)
wait_for_import_completion()
assert has_element?(view, "[data-testid='import-progress-container']")
html = render(view)
@ -275,4 +297,187 @@ defmodule MvWeb.ImportLiveTest do
html = render(view)
assert html =~ "Failed to prepare"
end
describe "preview state machine" do
setup %{conn: conn} do
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
conn =
conn
|> MvWeb.ConnCase.conn_with_password_user(admin_user)
|> put_locale_en()
valid_csv =
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|> File.read!()
{:ok, conn: conn, valid_csv: valid_csv}
end
test "start_import transitions to preview without processing", %{
conn: conn,
valid_csv: csv_content
} do
{:ok, view, _html} = live(conn, ~p"/admin/import")
upload_csv_file(view, csv_content)
submit_import(view)
# Preview is shown; no results panel yet because nothing was processed.
assert has_element?(view, "[data-testid='import-preview']")
refute has_element?(view, "[data-testid='import-results-panel']")
# No member was created during preview (read-only step).
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, members} = Membership.list_members(actor: system_actor)
refute Enum.any?(
members,
&(&1.email in ["alice.smith@example.com", "bob.johnson@example.com"])
)
end
test "confirm_import starts processing and creates members", %{
conn: conn,
valid_csv: csv_content
} do
{:ok, view, _html} = live(conn, ~p"/admin/import")
run_full_import(view, csv_content)
wait_for_import_completion()
assert has_element?(view, "[data-testid='import-results-panel']")
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, members} = Membership.list_members(actor: system_actor)
imported =
Enum.filter(
members,
&(&1.email in ["alice.smith@example.com", "bob.johnson@example.com"])
)
assert length(imported) == 2
end
test "cancel_import returns to idle and hides the preview", %{
conn: conn,
valid_csv: csv_content
} do
{:ok, view, _html} = live(conn, ~p"/admin/import")
upload_csv_file(view, csv_content)
submit_import(view)
assert has_element?(view, "[data-testid='import-preview']")
view |> element("[data-testid='cancel-import-button']") |> render_click()
refute has_element?(view, "[data-testid='import-preview']")
refute has_element?(view, "[data-testid='import-results-panel']")
end
end
describe "preview contents" do
setup %{conn: conn} do
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
conn =
conn
|> MvWeb.ConnCase.conn_with_password_user(admin_user)
|> put_locale_en()
{:ok, conn: conn}
end
test "shows the column mapping table with roles for each column", %{conn: conn} do
csv = "email;Gruppen;Beitragsart;Bezahlstatus;UnknownCol\na@e.com;Chor;Premium;paid;x"
{:ok, view, _html} = live(conn, ~p"/admin/import")
upload_csv_file(view, csv)
submit_import(view)
assert has_element?(view, "[data-testid='preview-mapping-table']")
html = render(view)
assert html =~ "email"
assert html =~ "Gruppen"
assert html =~ "Beitragsart"
assert html =~ "Bezahlstatus"
assert html =~ "UnknownCol"
end
test "lists every CSV column exactly once in the mapping table", %{conn: conn} do
headers = ["email", "Gruppen", "Beitragsart", "Bezahlstatus", "UnknownCol"]
csv = Enum.join(headers, ";") <> "\na@e.com;Chor;Premium;paid;x"
{:ok, view, _html} = live(conn, ~p"/admin/import")
upload_csv_file(view, csv)
submit_import(view)
# Count the data rows via their stable testid so the assertion is independent
# of how Phoenix renders class attributes or tr tags (§1.15).
html = render(view)
row_count =
html |> String.split(~s(data-testid="preview-column-row")) |> length() |> Kernel.-(1)
assert row_count == length(headers)
end
test "shows up to 3 sample data rows", %{conn: conn} do
csv = "email\nr1@e.com\nr2@e.com\nr3@e.com\nr4@e.com"
{:ok, view, _html} = live(conn, ~p"/admin/import")
upload_csv_file(view, csv)
submit_import(view)
html = render(view)
assert html =~ "r1@e.com"
assert html =~ "r2@e.com"
assert html =~ "r3@e.com"
refute html =~ "r4@e.com"
end
test "shows an auto-create notice for unknown group names", %{conn: conn} do
csv = "email;Gruppen\na@e.com;Ganz Neue Gruppe"
{:ok, view, _html} = live(conn, ~p"/admin/import")
upload_csv_file(view, csv)
submit_import(view)
assert has_element?(view, "[data-testid='preview-groups-notice']")
assert render(view) =~ "Ganz Neue Gruppe"
end
test "shows a warning and link for unknown fee-type names", %{conn: conn} do
csv = "email;Beitragsart\na@e.com;Phantom Tarif"
{:ok, view, _html} = live(conn, ~p"/admin/import")
upload_csv_file(view, csv)
submit_import(view)
assert has_element?(view, "[data-testid='preview-fee-type-warning']")
html = render(view)
assert html =~ "Phantom Tarif"
assert html =~ "/membership_fee_settings"
end
test "shows an info notice when fee-type cells are empty", %{conn: conn} do
csv = "email;Beitragsart\na@e.com;\nb@e.com;"
{:ok, view, _html} = live(conn, ~p"/admin/import")
upload_csv_file(view, csv)
submit_import(view)
assert has_element?(view, "[data-testid='preview-fee-type-info']")
end
test "shows a warning for unknown custom-field columns", %{conn: conn} do
csv = "email;TotallyUnknown\na@e.com;value"
{:ok, view, _html} = live(conn, ~p"/admin/import")
upload_csv_file(view, csv)
submit_import(view)
assert has_element?(view, "[data-testid='preview-unknown-warning']")
assert render(view) =~ "TotallyUnknown"
end
end
end

View file

@ -165,6 +165,36 @@ defmodule MvWeb.JoinLiveTest do
custom_field.name
)
end
@tag role: :unauthenticated
test "renders join_description with rendered link as label when set", %{conn: conn} do
{:ok, settings} = Membership.get_settings()
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, custom_field} =
Membership.create_custom_field(
%{
name: "DSGVO",
value_type: :boolean,
join_description: "Akzeptiere die [Datenschutzerklärung](https://example.com/dsgvo)"
},
actor: system_actor
)
{:ok, _} =
Membership.update_settings(settings, %{
join_form_enabled: true,
join_form_field_ids: ["email", custom_field.id],
join_form_field_required: %{"email" => true, custom_field.id => false}
})
{:ok, _view, html} = live(conn, "/join")
assert html =~
~s(<a href="https://example.com/dsgvo" class="link link-primary">Datenschutzerklärung</a>)
assert html =~ "Akzeptiere die"
end
end
describe "join field input types" do

View file

@ -220,4 +220,59 @@ defmodule MvWeb.MemberLive.ShowTest do
assert html =~ "private@example.com"
end
end
describe "custom field join_description tooltip" do
test "shows a tooltip on the custom field label when join_description is set", %{
conn: conn,
member: member,
actor: actor
} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "DSGVO",
value_type: :boolean,
join_description: "Accept the privacy policy"
})
|> Ash.create(actor: actor)
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, ~p"/members/#{member}")
assert has_element?(view, "[data-tip*='Accept the privacy policy']")
# Tooltip content conveys both the join-form context and the description text.
assert has_element?(view, "[data-tip*='Join form:']")
assert html =~ "Accept the privacy policy"
assert html =~ custom_field.name
# The info-icon wrapper must center the icon vertically with the label,
# matching the flex-items-center idiom used elsewhere (e.g. custom field edit),
# so the icon is flush with the label text and not offset downward.
assert has_element?(
view,
"[data-tip*='Accept the privacy policy'].inline-flex.items-center"
)
end
test "shows no tooltip on the custom field label when join_description is nil", %{
conn: conn,
member: member,
actor: actor
} do
{:ok, _custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Plain field",
value_type: :string
})
|> Ash.create(actor: actor)
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, ~p"/members/#{member}")
assert has_element?(view, "dt", "Plain field")
# The info-icon tooltip beside the label is only rendered when join_description is set.
refute has_element?(view, "[data-testid='join-description-tooltip']")
end
end
end