diff --git a/test/mv/membership/member_export_sort_test.exs b/test/mv/membership/member_export_sort_test.exs new file mode 100644 index 0000000..812a386 --- /dev/null +++ b/test/mv/membership/member_export_sort_test.exs @@ -0,0 +1,90 @@ +defmodule Mv.Membership.MemberExportSortTest do + use ExUnit.Case, async: true + + alias Mv.Membership.MemberExportSort + + describe "custom_field_sort_key/2" do + test "nil has rank 1 (sorts last in asc, first in desc)" do + assert MemberExportSort.custom_field_sort_key(:string, nil) == {1, nil} + assert MemberExportSort.custom_field_sort_key(:date, nil) == {1, nil} + end + + test "date: chronological key (ISO8601 string)" do + earlier = ~D[2023-01-15] + later = ~D[2024-06-01] + assert MemberExportSort.custom_field_sort_key(:date, earlier) == {0, "2023-01-15"} + assert MemberExportSort.custom_field_sort_key(:date, later) == {0, "2024-06-01"} + assert {0, "2023-01-15"} < {0, "2024-06-01"} + end + + test "date + nil: nil sorts after any date in asc" do + key_date = MemberExportSort.custom_field_sort_key(:date, ~D[2024-01-01]) + key_nil = MemberExportSort.custom_field_sort_key(:date, nil) + assert key_date == {0, "2024-01-01"} + assert key_nil == {1, nil} + assert key_date < key_nil + end + + test "boolean: false < true" do + key_f = MemberExportSort.custom_field_sort_key(:boolean, false) + key_t = MemberExportSort.custom_field_sort_key(:boolean, true) + assert key_f == {0, 0} + assert key_t == {0, 1} + assert key_f < key_t + end + + test "boolean + nil: nil sorts after false and true in asc" do + key_f = MemberExportSort.custom_field_sort_key(:boolean, false) + key_t = MemberExportSort.custom_field_sort_key(:boolean, true) + key_nil = MemberExportSort.custom_field_sort_key(:boolean, nil) + assert key_f < key_nil and key_t < key_nil + end + + test "integer: numerical key" do + assert MemberExportSort.custom_field_sort_key(:integer, 10) == {0, 10} + assert MemberExportSort.custom_field_sort_key(:integer, -5) == {0, -5} + assert MemberExportSort.custom_field_sort_key(:integer, 0) == {0, 0} + assert {0, -5} < {0, 0} and {0, 0} < {0, 10} + end + + test "string: case-insensitive key (downcased)" do + key_a = MemberExportSort.custom_field_sort_key(:string, "Anna") + key_b = MemberExportSort.custom_field_sort_key(:string, "bert") + assert key_a == {0, "anna"} + assert key_b == {0, "bert"} + assert key_a < key_b + end + + test "email: case-insensitive key" do + assert MemberExportSort.custom_field_sort_key(:email, "User@Example.com") == + {0, "user@example.com"} + end + + test "Ash.Union value is unwrapped" do + union = %Ash.Union{value: ~D[2024-01-01], type: :date} + assert MemberExportSort.custom_field_sort_key(:date, union) == {0, "2024-01-01"} + end + end + + describe "key_lt/3" do + test "asc: smaller key first, nil last" do + k_nil = {1, nil} + k_early = {0, "2023-01-01"} + k_late = {0, "2024-01-01"} + refute MemberExportSort.key_lt(k_nil, k_early, "asc") + refute MemberExportSort.key_lt(k_nil, k_late, "asc") + assert MemberExportSort.key_lt(k_early, k_late, "asc") + assert MemberExportSort.key_lt(k_early, k_nil, "asc") + end + + test "desc: larger key first, nil first" do + k_nil = {1, nil} + k_early = {0, "2023-01-01"} + k_late = {0, "2024-01-01"} + assert MemberExportSort.key_lt(k_nil, k_early, "desc") + assert MemberExportSort.key_lt(k_nil, k_late, "desc") + assert MemberExportSort.key_lt(k_late, k_early, "desc") + refute MemberExportSort.key_lt(k_early, k_nil, "desc") + end + end +end diff --git a/test/mv/membership/members_csv_test.exs b/test/mv/membership/members_csv_test.exs index a2228aa..a8688bf 100644 --- a/test/mv/membership/members_csv_test.exs +++ b/test/mv/membership/members_csv_test.exs @@ -3,81 +3,118 @@ defmodule Mv.Membership.MembersCSVTest do alias Mv.Membership.MembersCSV - describe "export/3" do + describe "export/2" do test "returns CSV with header and one data row (member fields only)" do member = %{first_name: "Jane", email: "jane@example.com"} - member_fields = ["first_name", "email"] - custom_fields_by_id = %{} - iodata = MembersCSV.export([member], member_fields, custom_fields_by_id) + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Email", kind: :member_field, key: "email"} + ] + + iodata = MembersCSV.export([member], columns) csv = IO.iodata_to_binary(iodata) - assert csv =~ "first_name" - assert csv =~ "email" + assert csv =~ "First Name" + assert csv =~ "Email" assert csv =~ "Jane" assert csv =~ "jane@example.com" - # One header line, one data line lines = String.split(csv, "\n", trim: true) assert length(lines) == 2 end + test "header uses display labels not raw field names (regression guard)" do + member = %{first_name: "Jane", email: "jane@example.com"} + + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Email", kind: :member_field, key: "email"} + ] + + iodata = MembersCSV.export([member], columns) + csv = IO.iodata_to_binary(iodata) + header_line = csv |> String.split("\n", trim: true) |> hd() + + assert header_line =~ "First Name" + assert header_line =~ "Email" + refute header_line =~ "first_name" + refute header_line =~ "email" + end + test "escapes cell containing comma (RFC 4180 quoted)" do member = %{first_name: "Doe, John", email: "john@example.com"} - member_fields = ["first_name", "email"] - custom_fields_by_id = %{} - iodata = MembersCSV.export([member], member_fields, custom_fields_by_id) + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Email", kind: :member_field, key: "email"} + ] + + iodata = MembersCSV.export([member], columns) csv = IO.iodata_to_binary(iodata) - # Comma inside value must be quoted so the cell is one field assert csv =~ ~s("Doe, John") assert csv =~ "john@example.com" end test "escapes cell containing double-quote (RFC 4180 doubled and quoted)" do member = %{first_name: ~s(He said "Hi"), email: "a@b.com"} - member_fields = ["first_name", "email"] - custom_fields_by_id = %{} - iodata = MembersCSV.export([member], member_fields, custom_fields_by_id) + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Email", kind: :member_field, key: "email"} + ] + + iodata = MembersCSV.export([member], columns) csv = IO.iodata_to_binary(iodata) - # Double-quote inside value must be doubled and cell quoted assert csv =~ ~s("He said ""Hi""") assert csv =~ "a@b.com" end test "formats date as ISO8601 for member fields" do member = %{first_name: "D", email: "d@d.com", join_date: ~D[2024-03-15]} - iodata = MembersCSV.export([member], ["first_name", "email", "join_date"], %{}) + + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Email", kind: :member_field, key: "email"}, + %{header: "Join Date", kind: :member_field, key: "join_date"} + ] + + iodata = MembersCSV.export([member], columns) csv = IO.iodata_to_binary(iodata) assert csv =~ "2024-03-15" - assert csv =~ "join_date" + assert csv =~ "Join Date" end test "formats nil as empty string" do member = %{first_name: "Only", last_name: nil, email: "x@y.com"} - member_fields = ["first_name", "last_name", "email"] - custom_fields_by_id = %{} - iodata = MembersCSV.export([member], member_fields, custom_fields_by_id) + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Last Name", kind: :member_field, key: "last_name"}, + %{header: "Email", kind: :member_field, key: "email"} + ] + + iodata = MembersCSV.export([member], columns) csv = IO.iodata_to_binary(iodata) - assert csv =~ "first_name" + assert csv =~ "First Name" assert csv =~ "Only" assert csv =~ "x@y.com" - # Nil becomes empty; between Only and x@y we have empty (e.g. Only,,x@y.com) assert csv =~ "Only,,x@y" end - test "formats boolean as true/false" do - # Use a field we can set to boolean via a custom-like struct - member has no boolean field. - # So we test via custom field instead. + test "custom field column uses header and formats value" do custom_cf = %{id: "cf-1", name: "Active", value_type: :boolean} - custom_fields_by_id = %{"cf-1" => custom_cf} - member_with_cfv = %{ + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Email", kind: :member_field, key: "email"}, + %{header: "Active", kind: :custom_field, key: "cf-1", custom_field: custom_cf} + ] + + member = %{ first_name: "Test", email: "e@e.com", custom_field_values: [ @@ -85,24 +122,157 @@ defmodule Mv.Membership.MembersCSVTest do ] } - iodata = - MembersCSV.export( - [member_with_cfv], - ["first_name", "email"], - custom_fields_by_id - ) - + iodata = MembersCSV.export([member], columns) csv = IO.iodata_to_binary(iodata) + assert csv =~ "Active" - # Formatter yields "Yes" for true (gettext) assert csv =~ "Yes" end - test "includes custom field columns in header and rows (order from map)" do + test "custom field uses display_name when present, else name" do + custom_cf = %{id: "cf-a", name: "FieldA", value_type: :string} + + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{ + header: "Display Label", + kind: :custom_field, + key: "cf-a", + custom_field: Map.put(custom_cf, :display_name, "Display Label") + } + ] + + member = %{ + first_name: "X", + email: "x@x.com", + custom_field_values: [ + %{custom_field_id: "cf-a", value: "only_a", custom_field: custom_cf} + ] + } + + iodata = MembersCSV.export([member], columns) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ "Display Label" + assert csv =~ "only_a" + end + + test "missing custom field value yields empty cell" do + cf1 = %{id: "cf-a", name: "FieldA", value_type: :string} + cf2 = %{id: "cf-b", name: "FieldB", value_type: :string} + + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Email", kind: :member_field, key: "email"}, + %{header: "FieldA", kind: :custom_field, key: "cf-a", custom_field: cf1}, + %{header: "FieldB", kind: :custom_field, key: "cf-b", custom_field: cf2} + ] + + member = %{ + first_name: "X", + email: "x@x.com", + custom_field_values: [%{custom_field_id: "cf-a", value: "only_a", custom_field: cf1}] + } + + iodata = MembersCSV.export([member], columns) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ "First Name,Email,FieldA,FieldB" + assert csv =~ "only_a" + assert csv =~ "X,x@x.com,only_a," + end + + test "computed column exports membership fee status label" do + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Email", kind: :member_field, key: "email"}, + %{header: "Membership Fee Status", kind: :computed, key: :membership_fee_status} + ] + + member = %{first_name: "M", email: "m@m.com", membership_fee_status: "Paid"} + + iodata = MembersCSV.export([member], columns) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ "Membership Fee Status" + assert csv =~ "Paid" + assert csv =~ "M,m@m.com,Paid" + end + + test "computed column with payment_status key exports same value (alias)" do + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Membership Fee Status", kind: :computed, key: :payment_status} + ] + + member = %{first_name: "X", payment_status: "Unpaid"} + + iodata = MembersCSV.export([member], columns) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ "Membership Fee Status" + assert csv =~ "Unpaid" + assert csv =~ "X,Unpaid" + end + + test "CSV injection: formula-like and dangerous prefixes are escaped with apostrophe" do + member = %{ + first_name: "=SUM(A1:A10)", + last_name: "+1", + email: "@cmd|evil" + } + + custom_cf = %{id: "cf-1", name: "Note", value_type: :string} + + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Last Name", kind: :member_field, key: "last_name"}, + %{header: "Email", kind: :member_field, key: "email"}, + %{header: "Note", kind: :custom_field, key: "cf-1", custom_field: custom_cf} + ] + + member_with_cf = + Map.put(member, :custom_field_values, [ + %{custom_field_id: "cf-1", value: "normal text", custom_field: custom_cf} + ]) + + iodata = MembersCSV.export([member_with_cf], columns) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ "'=SUM(A1:A10)" + assert csv =~ "'+1" + assert csv =~ "'@cmd|evil" + assert csv =~ "normal text" + refute csv =~ ",'normal text" + end + + test "CSV injection: minus and tab prefix are escaped" do + member = %{first_name: "-2", last_name: "\tleading", email: "safe@x.com"} + + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Last Name", kind: :member_field, key: "last_name"}, + %{header: "Email", kind: :member_field, key: "email"} + ] + + iodata = MembersCSV.export([member], columns) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ "'-2" + assert csv =~ "'\tleading" + assert csv =~ "safe@x.com" + end + + test "column order is preserved (headers and values)" do cf1 = %{id: "a", name: "Custom1", value_type: :string} cf2 = %{id: "b", name: "Custom2", value_type: :string} - # Map order: a then b - custom_fields_by_id = %{"a" => cf1, "b" => cf2} + + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Email", kind: :member_field, key: "email"}, + %{header: "Custom2", kind: :custom_field, key: "b", custom_field: cf2}, + %{header: "Custom1", kind: :custom_field, key: "a", custom_field: cf1} + ] member = %{ first_name: "M", @@ -113,12 +283,11 @@ defmodule Mv.Membership.MembersCSVTest do ] } - iodata = MembersCSV.export([member], ["first_name", "email"], custom_fields_by_id) + iodata = MembersCSV.export([member], columns) csv = IO.iodata_to_binary(iodata) - assert csv =~ "first_name,email,Custom1,Custom2" - assert csv =~ "v1" - assert csv =~ "v2" + assert csv =~ "First Name,Email,Custom2,Custom1" + assert csv =~ "M,m@m.com,v2,v1" end end end diff --git a/test/mv_web/components/search_bar_component_test.exs b/test/mv_web/components/search_bar_component_test.exs index bc8bc46..1043c1f 100644 --- a/test/mv_web/components/search_bar_component_test.exs +++ b/test/mv_web/components/search_bar_component_test.exs @@ -15,19 +15,19 @@ defmodule MvWeb.Components.SearchBarComponentTest do {:ok, view, _html} = live(conn, "/members") # simulate search input and check that other members are not listed - html = + _html = view |> element("form[role=search]") |> render_submit(%{"query" => "Friedrich"}) - refute html =~ "Greta" + refute has_element?(view, "input[data-testid='search-input'][value='Greta']") - html = + _html = view |> element("form[role=search]") |> render_submit(%{"query" => "Greta"}) - refute html =~ "Friedrich" + refute has_element?(view, "input[data-testid='search-input'][value='Friedrich']") end end end diff --git a/test/mv_web/controllers/member_export_controller_test.exs b/test/mv_web/controllers/member_export_controller_test.exs index 122011b..34f5a75 100644 --- a/test/mv_web/controllers/member_export_controller_test.exs +++ b/test/mv_web/controllers/member_export_controller_test.exs @@ -70,10 +70,10 @@ defmodule MvWeb.MemberExportControllerTest do body = response(conn, 200) lines = String.split(body, "\n", trim: true) - # Header + 2 data rows + # Header + 2 data rows (headers are localized labels) assert length(lines) == 3 - assert hd(lines) =~ "first_name" - assert hd(lines) =~ "email" + assert hd(lines) =~ "First Name" + assert hd(lines) =~ "Email" assert body =~ "Alice" assert body =~ "Bob" refute body =~ "Carol" @@ -107,9 +107,9 @@ defmodule MvWeb.MemberExportControllerTest do body = response(conn, 200) lines = String.split(body, "\n", trim: true) - # Header + at least 3 data rows + # Header + at least 3 data rows (headers are localized labels) assert length(lines) >= 4 - assert hd(lines) =~ "first_name" + assert hd(lines) =~ "First Name" assert body =~ "Alice" assert body =~ "Bob" assert body =~ "Carol" @@ -138,9 +138,106 @@ defmodule MvWeb.MemberExportControllerTest do body = response(conn, 200) header = body |> String.split("\n", trim: true) |> hd() - assert header =~ "first_name" - assert header =~ "email" + assert header =~ "First Name" + assert header =~ "Email" refute header =~ "unknown_field" end + + test "export includes membership_fee_status column when requested", %{ + conn: conn, + member1: m1 + } do + payload = %{ + "selected_ids" => [m1.id], + "member_fields" => ["first_name", "membership_fee_status"], + "custom_field_ids" => [], + "query" => nil, + "sort_field" => nil, + "sort_order" => nil + } + + conn = get(conn, "/members") + csrf_token = csrf_token_from_conn(conn) + + conn = + post(conn, "/members/export.csv", %{ + "payload" => Jason.encode!(payload), + "_csrf_token" => csrf_token + }) + + assert conn.status == 200 + body = response(conn, 200) + header = body |> String.split("\n", trim: true) |> hd() + + assert header =~ "First Name" + assert header =~ "Membership Fee Status" + assert body =~ "Alice" + end + + test "export with payment_status alias: header shows Membership Fee Status", %{ + conn: conn, + member1: m1 + } do + payload = %{ + "selected_ids" => [m1.id], + "member_fields" => ["first_name", "payment_status"], + "custom_field_ids" => [], + "query" => nil, + "sort_field" => nil, + "sort_order" => nil + } + + conn = get(conn, "/members") + csrf_token = csrf_token_from_conn(conn) + + conn = + post(conn, "/members/export.csv", %{ + "payload" => Jason.encode!(payload), + "_csrf_token" => csrf_token + }) + + assert conn.status == 200 + body = response(conn, 200) + header = body |> String.split("\n", trim: true) |> hd() + + assert header =~ "Membership Fee Status" + assert body =~ "Alice" + end + + test "export with show_current_cycle: membership fee status column exists stably", %{ + conn: conn, + member1: _m1, + member2: _m2, + member3: _m3 + } do + payload = %{ + "selected_ids" => [], + "member_fields" => ["first_name", "email", "membership_fee_status"], + "custom_field_ids" => [], + "query" => nil, + "sort_field" => nil, + "sort_order" => nil, + "show_current_cycle" => true + } + + conn = get(conn, "/members") + csrf_token = csrf_token_from_conn(conn) + + conn = + post(conn, "/members/export.csv", %{ + "payload" => Jason.encode!(payload), + "_csrf_token" => csrf_token + }) + + assert conn.status == 200 + body = response(conn, 200) + lines = String.split(body, "\n", trim: true) + + assert length(lines) >= 4 + header = hd(lines) + assert header =~ "First Name" + assert header =~ "Email" + assert header =~ "Membership Fee Status" + end end end diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index e560c92..9d4a429 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -48,34 +48,28 @@ defmodule MvWeb.MemberLive.IndexTest do describe "translations" do @describetag :ui - test "shows translated title in German", %{conn: conn} do - conn = conn_with_oidc_user(conn) - conn = Plug.Test.init_test_session(conn, locale: "de") - {:ok, _view, html} = live(conn, "/members") - # Expected German title - assert html =~ "Mitglieder" - end - test "shows translated title in English", %{conn: conn} do - conn = conn_with_oidc_user(conn) - Gettext.put_locale(MvWeb.Gettext, "en") - {:ok, _view, html} = live(conn, "/members") - # Expected English title - assert html =~ "Members" - end + test "shows translated title and button text by locale", %{conn: conn} do + locales = [ + {"de", "Mitglieder", "Speichern", + fn c -> Plug.Test.init_test_session(c, locale: "de") end}, + {"en", "Members", "Save", + fn c -> + Gettext.put_locale(MvWeb.Gettext, "en") + c + end} + ] - test "shows translated button text in German", %{conn: conn} do - conn = conn_with_oidc_user(conn) - conn = Plug.Test.init_test_session(conn, locale: "de") - {:ok, _view, html} = live(conn, "/members/new") - assert html =~ "Speichern" - end + for {_locale, expected_title, expected_button, set_locale} <- locales do + base = conn_with_oidc_user(conn) |> set_locale.() - test "shows translated button text in English", %{conn: conn} do - conn = conn_with_oidc_user(conn) - Gettext.put_locale(MvWeb.Gettext, "en") - {:ok, _view, html} = live(conn, "/members/new") - assert html =~ "Save" + {:ok, _view, index_html} = live(base, "/members") + assert index_html =~ expected_title + + base_form = conn_with_oidc_user(conn) |> set_locale.() + {:ok, _view, form_html} = live(base_form, "/members/new") + assert form_html =~ expected_button + end end test "shows translated flash message after creating a member in German", %{conn: conn} do @@ -543,7 +537,7 @@ defmodule MvWeb.MemberLive.IndexTest do test "export button is rendered when no selection and shows (all)", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, html} = live(conn, "/members") + {:ok, _view, html} = live(conn, "/members") # Button text shows "all" when 0 selected (locale-dependent) assert html =~ "Export to CSV"