tests: update tests
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
carla 2026-02-05 15:03:36 +01:00
parent 9b9e7ec995
commit 8e387d8e17
5 changed files with 429 additions and 79 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"