This commit is contained in:
parent
9b9e7ec995
commit
8e387d8e17
5 changed files with 429 additions and 79 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue