defmodule MvWeb.MemberExportControllerTest do use MvWeb.ConnCase, async: true alias Mv.Fixtures defp csrf_token_from_conn(conn) do get_session(conn, "_csrf_token") || csrf_token_from_html(response(conn, 200)) end defp csrf_token_from_html(html) when is_binary(html) do case Regex.run(~r/name="csrf-token"\s+content="([^"]+)"/, html) do [_, token] -> token _ -> nil end end # Export uses humanize_field (e.g. "first_name" -> "First name"); normalize \r\n line endings defp export_lines(body) do body |> String.split(~r/\r?\n/, trim: true) end describe "POST /members/export.csv" do setup %{conn: conn} do # Create 3 members for export tests m1 = Fixtures.member_fixture(%{ first_name: "Alice", last_name: "One", email: "alice.one@example.com" }) m2 = Fixtures.member_fixture(%{ first_name: "Bob", last_name: "Two", email: "bob.two@example.com" }) m3 = Fixtures.member_fixture(%{ first_name: "Carol", last_name: "Three", email: "carol.three@example.com" }) %{member1: m1, member2: m2, member3: m3, conn: conn} end test "exports selected members with specified fields", %{ conn: conn, member1: m1, member2: m2 } do payload = %{ "selected_ids" => [m1.id, m2.id], "member_fields" => ["first_name", "last_name", "email"], "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 assert get_resp_header(conn, "content-type") |> List.first() =~ "text/csv" body = response(conn, 200) lines = export_lines(body) header = hd(lines) # Header + 2 data rows (controller uses humanize_field: "first_name" -> "First name") assert length(lines) == 3 assert header =~ "First Name,Last Name,Email" assert body =~ "Alice" assert body =~ "Bob" refute body =~ "Carol" end test "exports all members when selected_ids is empty", %{ conn: conn, member1: _m1, member2: _m2, member3: _m3 } do payload = %{ "selected_ids" => [], "member_fields" => ["first_name", "email"], "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) lines = export_lines(body) # Header + at least 3 data rows (controller uses humanize_field) assert length(lines) >= 4 assert body =~ "Alice" assert body =~ "Bob" assert body =~ "Carol" end test "filters out unknown member fields from export", %{conn: conn, member1: m1} do payload = %{ "selected_ids" => [m1.id], "member_fields" => ["first_name", "unknown_field", "email"], "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 |> export_lines() |> hd() assert header =~ "First Name,Email" refute header =~ "unknown_field" end test "export includes membership_fee_status computed field when requested", %{ conn: conn, member1: m1 } do payload = %{ "selected_ids" => [m1.id], "member_fields" => ["first_name"], "computed_fields" => ["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 |> export_lines() |> hd() assert header =~ "First Name,Membership Fee Status" assert body =~ "Alice" end test "exports membership fee status computed field with show_current_cycle option", %{ conn: conn, member1: _m1, member2: _m2, member3: _m3 } do payload = %{ "selected_ids" => [], "member_fields" => [], "computed_fields" => ["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 = export_lines(body) header = hd(lines) assert header =~ "Membership Fee Status" end setup %{conn: conn} do system_actor = Mv.Helpers.SystemActor.get_system_actor() # Create custom fields for different types {:ok, string_field} = Mv.Membership.CustomField |> Ash.Changeset.for_create(:create, %{ name: "Phone Number", value_type: :string }) |> Ash.create(actor: system_actor) {:ok, integer_field} = Mv.Membership.CustomField |> Ash.Changeset.for_create(:create, %{ name: "Membership Number", value_type: :integer }) |> Ash.create(actor: system_actor) {:ok, boolean_field} = Mv.Membership.CustomField |> Ash.Changeset.for_create(:create, %{ name: "Active Member", value_type: :boolean }) |> Ash.create(actor: system_actor) # Create members with custom field values {:ok, member_with_string} = Mv.Membership.create_member( %{ first_name: "Test", last_name: "String", email: "test.string@example.com" }, actor: system_actor ) {:ok, _cfv_string} = Mv.Membership.CustomFieldValue |> Ash.Changeset.for_create(:create, %{ member_id: member_with_string.id, custom_field_id: string_field.id, value: "+49 123 456789" }) |> Ash.create(actor: system_actor) {:ok, member_with_integer} = Mv.Membership.create_member( %{ first_name: "Test", last_name: "Integer", email: "test.integer@example.com" }, actor: system_actor ) {:ok, _cfv_integer} = Mv.Membership.CustomFieldValue |> Ash.Changeset.for_create(:create, %{ member_id: member_with_integer.id, custom_field_id: integer_field.id, value: 12345 }) |> Ash.create(actor: system_actor) {:ok, member_with_boolean} = Mv.Membership.create_member( %{ first_name: "Test", last_name: "Boolean", email: "test.boolean@example.com" }, actor: system_actor ) {:ok, _cfv_boolean} = Mv.Membership.CustomFieldValue |> Ash.Changeset.for_create(:create, %{ member_id: member_with_boolean.id, custom_field_id: boolean_field.id, value: true }) |> Ash.create(actor: system_actor) {:ok, member_without_value} = Mv.Membership.create_member( %{ first_name: "Test", last_name: "NoValue", email: "test.novalue@example.com" }, actor: system_actor ) %{ conn: conn, string_field: string_field, integer_field: integer_field, boolean_field: boolean_field, member_with_string: member_with_string, member_with_integer: member_with_integer, member_with_boolean: member_with_boolean, member_without_value: member_without_value } end test "export includes custom field column with string value", %{ conn: conn, string_field: string_field, member_with_string: member } do payload = %{ "selected_ids" => [member.id], "member_fields" => ["first_name", "last_name"], "custom_field_ids" => [string_field.id], "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) lines = export_lines(body) header = hd(lines) assert header =~ "First Name" assert header =~ "Last Name" assert header =~ "Phone Number" assert body =~ "Test" assert body =~ "String" assert body =~ "+49 123 456789" end test "export includes custom field column with integer value", %{ conn: conn, integer_field: integer_field, member_with_integer: member } do payload = %{ "selected_ids" => [member.id], "member_fields" => ["first_name"], "custom_field_ids" => [integer_field.id], "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 |> export_lines() |> hd() assert header =~ "First Name" assert header =~ "Membership Number" assert body =~ "Test" assert body =~ "12345" end test "export includes custom field column with boolean value", %{ conn: conn, boolean_field: boolean_field, member_with_boolean: member } do payload = %{ "selected_ids" => [member.id], "member_fields" => ["first_name"], "custom_field_ids" => [boolean_field.id], "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 |> export_lines() |> hd() assert header =~ "First Name" assert header =~ "Active Member" assert body =~ "Test" # Boolean values are formatted as "Yes" or "No" by CustomFieldValueFormatter assert body =~ "Yes" end test "export shows empty cell for member without custom field value", %{ conn: conn, string_field: string_field, member_without_value: member } do payload = %{ "selected_ids" => [member.id], "member_fields" => ["first_name", "last_name"], "custom_field_ids" => [string_field.id], "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) lines = export_lines(body) header = hd(lines) data_line = Enum.at(lines, 1) assert header =~ "Phone Number" # Empty custom field value should result in empty cell (two consecutive commas) assert data_line =~ "Test,NoValue," end test "export includes multiple custom fields in correct order", %{ conn: conn, string_field: string_field, integer_field: integer_field, boolean_field: boolean_field, member_with_string: member } do payload = %{ "selected_ids" => [member.id], "member_fields" => ["first_name"], "custom_field_ids" => [string_field.id, integer_field.id, boolean_field.id], "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 |> export_lines() |> hd() assert header =~ "First Name" assert header =~ "Phone Number" assert header =~ "Membership Number" assert header =~ "Active Member" # Verify order: member fields first, then custom fields in the order specified header_parts = String.split(header, ",") first_name_idx = Enum.find_index(header_parts, &String.contains?(&1, "First Name")) phone_idx = Enum.find_index(header_parts, &String.contains?(&1, "Phone Number")) membership_idx = Enum.find_index(header_parts, &String.contains?(&1, "Membership Number")) active_idx = Enum.find_index(header_parts, &String.contains?(&1, "Active Member")) assert first_name_idx < phone_idx assert phone_idx < membership_idx assert membership_idx < active_idx end end end