defmodule Mv.Membership.MembersCSVTest do use ExUnit.Case, async: true alias Mv.Membership.MembersCSV 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"} 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 =~ "Jane" assert csv =~ "jane@example.com" 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"} 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 =~ ~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"} 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 =~ ~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]} 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" end test "formats nil as empty string" do member = %{first_name: "Only", last_name: nil, email: "x@y.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 =~ "First Name" assert csv =~ "Only" assert csv =~ "x@y.com" assert csv =~ "Only,,x@y" end test "custom field column uses header and formats value" do custom_cf = %{id: "cf-1", name: "Active", value_type: :boolean} 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: [ %{custom_field_id: "cf-1", value: true, custom_field: custom_cf} ] } iodata = MembersCSV.export([member], columns) csv = IO.iodata_to_binary(iodata) assert csv =~ "Active" assert csv =~ "Yes" end 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} 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", email: "m@m.com", custom_field_values: [ %{custom_field_id: "a", value: "v1", custom_field: cf1}, %{custom_field_id: "b", value: "v2", custom_field: cf2} ] } iodata = MembersCSV.export([member], columns) csv = IO.iodata_to_binary(iodata) assert csv =~ "First Name,Email,Custom2,Custom1" assert csv =~ "M,m@m.com,v2,v1" end end end