tests: add tests
This commit is contained in:
parent
e1266944b1
commit
9115d53198
6 changed files with 503 additions and 649 deletions
|
|
@ -1,8 +1,9 @@
|
||||||
defmodule Mv.Membership.GroupTest do
|
defmodule Mv.Membership.GroupTest do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Tests for Group resource validations, CRUD operations, and relationships.
|
Tests for Group resource validations, CRUD operations, and relationships.
|
||||||
|
Uses async: true; no shared DB state or sandbox constraints.
|
||||||
"""
|
"""
|
||||||
use Mv.DataCase, async: false
|
use Mv.DataCase, async: true
|
||||||
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
defmodule Mv.Membership.MemberGroupTest do
|
defmodule Mv.Membership.MemberGroupTest do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Tests for MemberGroup join table resource - validations and cascade delete behavior.
|
Tests for MemberGroup join table resource - validations and cascade delete behavior.
|
||||||
|
Uses async: true; no shared DB state or sandbox constraints.
|
||||||
"""
|
"""
|
||||||
use Mv.DataCase, async: false
|
use Mv.DataCase, async: true
|
||||||
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -199,22 +199,6 @@ defmodule Mv.Membership.MembersCSVTest do
|
||||||
assert csv =~ "M,m@m.com,Paid"
|
assert csv =~ "M,m@m.com,Paid"
|
||||||
end
|
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
|
test "CSV injection: formula-like and dangerous prefixes are escaped with apostrophe" do
|
||||||
member = %{
|
member = %{
|
||||||
first_name: "=SUM(A1:A10)",
|
first_name: "=SUM(A1:A10)",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,11 @@ defmodule MvWeb.MemberExportControllerTest do
|
||||||
end
|
end
|
||||||
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
|
describe "POST /members/export.csv" do
|
||||||
setup %{conn: conn} do
|
setup %{conn: conn} do
|
||||||
# Create 3 members for export tests
|
# Create 3 members for export tests
|
||||||
|
|
@ -41,7 +46,7 @@ defmodule MvWeb.MemberExportControllerTest do
|
||||||
%{member1: m1, member2: m2, member3: m3, conn: conn}
|
%{member1: m1, member2: m2, member3: m3, conn: conn}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "selected export: returns 200, text/csv, header + exactly 2 data rows", %{
|
test "exports selected members with specified fields", %{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
member1: m1,
|
member1: m1,
|
||||||
member2: m2
|
member2: m2
|
||||||
|
|
@ -68,18 +73,18 @@ defmodule MvWeb.MemberExportControllerTest do
|
||||||
assert get_resp_header(conn, "content-type") |> List.first() =~ "text/csv"
|
assert get_resp_header(conn, "content-type") |> List.first() =~ "text/csv"
|
||||||
|
|
||||||
body = response(conn, 200)
|
body = response(conn, 200)
|
||||||
lines = String.split(body, "\n", trim: true)
|
lines = export_lines(body)
|
||||||
|
header = hd(lines)
|
||||||
|
|
||||||
# Header + 2 data rows (headers are localized labels)
|
# Header + 2 data rows (controller uses humanize_field: "first_name" -> "First name")
|
||||||
assert length(lines) == 3
|
assert length(lines) == 3
|
||||||
assert hd(lines) =~ "First Name"
|
assert header =~ "First Name,Last Name,Email"
|
||||||
assert hd(lines) =~ "Email"
|
|
||||||
assert body =~ "Alice"
|
assert body =~ "Alice"
|
||||||
assert body =~ "Bob"
|
assert body =~ "Bob"
|
||||||
refute body =~ "Carol"
|
refute body =~ "Carol"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "all export: selected_ids=[] returns all members (at least 3 data rows)", %{
|
test "exports all members when selected_ids is empty", %{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
member1: _m1,
|
member1: _m1,
|
||||||
member2: _m2,
|
member2: _m2,
|
||||||
|
|
@ -105,17 +110,16 @@ defmodule MvWeb.MemberExportControllerTest do
|
||||||
|
|
||||||
assert conn.status == 200
|
assert conn.status == 200
|
||||||
body = response(conn, 200)
|
body = response(conn, 200)
|
||||||
lines = String.split(body, "\n", trim: true)
|
lines = export_lines(body)
|
||||||
|
|
||||||
# Header + at least 3 data rows (headers are localized labels)
|
# Header + at least 3 data rows (controller uses humanize_field)
|
||||||
assert length(lines) >= 4
|
assert length(lines) >= 4
|
||||||
assert hd(lines) =~ "First Name"
|
|
||||||
assert body =~ "Alice"
|
assert body =~ "Alice"
|
||||||
assert body =~ "Bob"
|
assert body =~ "Bob"
|
||||||
assert body =~ "Carol"
|
assert body =~ "Carol"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "whitelist: unknown member_fields are not in header", %{conn: conn, member1: m1} do
|
test "filters out unknown member fields from export", %{conn: conn, member1: m1} do
|
||||||
payload = %{
|
payload = %{
|
||||||
"selected_ids" => [m1.id],
|
"selected_ids" => [m1.id],
|
||||||
"member_fields" => ["first_name", "unknown_field", "email"],
|
"member_fields" => ["first_name", "unknown_field", "email"],
|
||||||
|
|
@ -136,20 +140,20 @@ defmodule MvWeb.MemberExportControllerTest do
|
||||||
|
|
||||||
assert conn.status == 200
|
assert conn.status == 200
|
||||||
body = response(conn, 200)
|
body = response(conn, 200)
|
||||||
header = body |> String.split("\n", trim: true) |> hd()
|
header = body |> export_lines() |> hd()
|
||||||
|
|
||||||
assert header =~ "First Name"
|
assert header =~ "First Name,Email"
|
||||||
assert header =~ "Email"
|
|
||||||
refute header =~ "unknown_field"
|
refute header =~ "unknown_field"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "export includes membership_fee_status column when requested", %{
|
test "export includes membership_fee_status computed field when requested", %{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
member1: m1
|
member1: m1
|
||||||
} do
|
} do
|
||||||
payload = %{
|
payload = %{
|
||||||
"selected_ids" => [m1.id],
|
"selected_ids" => [m1.id],
|
||||||
"member_fields" => ["first_name", "membership_fee_status"],
|
"member_fields" => ["first_name"],
|
||||||
|
"computed_fields" => ["membership_fee_status"],
|
||||||
"custom_field_ids" => [],
|
"custom_field_ids" => [],
|
||||||
"query" => nil,
|
"query" => nil,
|
||||||
"sort_field" => nil,
|
"sort_field" => nil,
|
||||||
|
|
@ -167,44 +171,13 @@ defmodule MvWeb.MemberExportControllerTest do
|
||||||
|
|
||||||
assert conn.status == 200
|
assert conn.status == 200
|
||||||
body = response(conn, 200)
|
body = response(conn, 200)
|
||||||
header = body |> String.split("\n", trim: true) |> hd()
|
header = body |> export_lines() |> hd()
|
||||||
|
|
||||||
assert header =~ "First Name"
|
assert header =~ "First Name,Membership Fee Status"
|
||||||
assert header =~ "Membership Fee Status"
|
|
||||||
assert body =~ "Alice"
|
assert body =~ "Alice"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "export with payment_status alias: header shows Membership Fee Status", %{
|
test "exports membership fee status computed field with show_current_cycle option", %{
|
||||||
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,
|
conn: conn,
|
||||||
member1: _m1,
|
member1: _m1,
|
||||||
member2: _m2,
|
member2: _m2,
|
||||||
|
|
@ -212,7 +185,8 @@ defmodule MvWeb.MemberExportControllerTest do
|
||||||
} do
|
} do
|
||||||
payload = %{
|
payload = %{
|
||||||
"selected_ids" => [],
|
"selected_ids" => [],
|
||||||
"member_fields" => ["first_name", "email", "membership_fee_status"],
|
"member_fields" => [],
|
||||||
|
"computed_fields" => ["membership_fee_status"],
|
||||||
"custom_field_ids" => [],
|
"custom_field_ids" => [],
|
||||||
"query" => nil,
|
"query" => nil,
|
||||||
"sort_field" => nil,
|
"sort_field" => nil,
|
||||||
|
|
@ -231,13 +205,300 @@ defmodule MvWeb.MemberExportControllerTest do
|
||||||
|
|
||||||
assert conn.status == 200
|
assert conn.status == 200
|
||||||
body = response(conn, 200)
|
body = response(conn, 200)
|
||||||
lines = String.split(body, "\n", trim: true)
|
lines = export_lines(body)
|
||||||
|
|
||||||
assert length(lines) >= 4
|
|
||||||
header = hd(lines)
|
header = hd(lines)
|
||||||
assert header =~ "First Name"
|
|
||||||
assert header =~ "Email"
|
|
||||||
assert header =~ "Membership Fee Status"
|
assert header =~ "Membership Fee Status"
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
defmodule MvWeb.ImportExportLiveTest do
|
defmodule MvWeb.ImportExportLiveTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for Import/Export LiveView: authorization (business rule), CSV import integration,
|
||||||
|
and minimal UI smoke tests. CSV parsing/validation logic is covered by
|
||||||
|
Mv.Membership.Import.MemberCSVTest; here we verify access control and end-to-end outcomes.
|
||||||
|
"""
|
||||||
use MvWeb.ConnCase, async: true
|
use MvWeb.ConnCase, async: true
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
# Helper function to upload CSV file in tests
|
alias Mv.Membership
|
||||||
# Reduces code duplication across multiple test cases
|
|
||||||
|
defp put_locale_en(conn), do: Plug.Conn.put_session(conn, "locale", "en")
|
||||||
|
|
||||||
defp upload_csv_file(view, csv_content, filename \\ "test_import.csv") do
|
defp upload_csv_file(view, csv_content, filename \\ "test_import.csv") do
|
||||||
view
|
view
|
||||||
|> file_input("#csv-upload-form", :csv_file, [
|
|> file_input("#csv-upload-form", :csv_file, [
|
||||||
|
|
@ -18,613 +25,135 @@ defmodule MvWeb.ImportExportLiveTest do
|
||||||
|> render_upload(filename)
|
|> render_upload(filename)
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "Import/Export LiveView" do
|
defp submit_import(view), do: view |> form("#csv-upload-form", %{}) |> render_submit()
|
||||||
@describetag :ui
|
|
||||||
setup %{conn: conn} do
|
|
||||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
|
||||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
|
|
||||||
{:ok, conn: conn, admin_user: admin_user}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "renders the import/export page", %{conn: conn} do
|
defp wait_for_import_completion, do: Process.sleep(1000)
|
||||||
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
|
|
||||||
|
|
||||||
assert html =~ "Import/Export"
|
# ---------- Business logic: Authorization ----------
|
||||||
end
|
describe "Authorization" do
|
||||||
|
test "non-admin user cannot access import/export page and sees permission error", %{
|
||||||
test "displays import section for admin user", %{conn: conn} do
|
conn: conn
|
||||||
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
|
} do
|
||||||
|
|
||||||
assert html =~ "Import Members (CSV)"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "displays export section placeholder", %{conn: conn} do
|
|
||||||
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
|
|
||||||
|
|
||||||
assert html =~ "Export Members (CSV)" or html =~ "Export"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "CSV Import Section" do
|
|
||||||
@describetag :ui
|
|
||||||
setup %{conn: conn} do
|
|
||||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
|
||||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
|
|
||||||
{:ok, conn: conn, admin_user: admin_user}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "admin user sees import section", %{conn: conn} do
|
|
||||||
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
|
|
||||||
|
|
||||||
# Check for import section heading or identifier
|
|
||||||
assert html =~ "Import" or html =~ "CSV" or html =~ "member_import"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "admin user sees custom fields notice", %{conn: conn} do
|
|
||||||
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
|
|
||||||
|
|
||||||
# Check for custom fields notice text
|
|
||||||
assert html =~ "Use the data field name"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "admin user sees template download links", %{conn: conn} do
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
|
||||||
|
|
||||||
html = render(view)
|
|
||||||
|
|
||||||
# Check for English template link
|
|
||||||
assert html =~ "member_import_en.csv" or html =~ "/templates/member_import_en.csv"
|
|
||||||
|
|
||||||
# Check for German template link
|
|
||||||
assert html =~ "member_import_de.csv" or html =~ "/templates/member_import_de.csv"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "template links use static path helper", %{conn: conn} do
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
|
||||||
|
|
||||||
html = render(view)
|
|
||||||
|
|
||||||
# Check that links contain the static path pattern
|
|
||||||
# Static paths typically start with /templates/ or contain the full path
|
|
||||||
assert html =~ "/templates/member_import_en.csv" or
|
|
||||||
html =~ ~r/href=["'][^"']*member_import_en\.csv["']/
|
|
||||||
|
|
||||||
assert html =~ "/templates/member_import_de.csv" or
|
|
||||||
html =~ ~r/href=["'][^"']*member_import_de\.csv["']/
|
|
||||||
end
|
|
||||||
|
|
||||||
test "admin user sees file upload input", %{conn: conn} do
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
|
||||||
|
|
||||||
html = render(view)
|
|
||||||
|
|
||||||
# Check for file input element
|
|
||||||
assert html =~ ~r/type=["']file["']/i or html =~ "phx-hook" or html =~ "upload"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "file upload has CSV-only restriction", %{conn: conn} do
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
|
||||||
|
|
||||||
html = render(view)
|
|
||||||
|
|
||||||
# Check for CSV file type restriction in help text or accept attribute
|
|
||||||
assert html =~ ~r/\.csv/i or html =~ "CSV" or html =~ ~r/accept=["'][^"']*csv["']/i
|
|
||||||
end
|
|
||||||
|
|
||||||
test "non-admin user sees permission error", %{conn: conn} do
|
|
||||||
# Member (own_data) user
|
|
||||||
member_user = Mv.Fixtures.user_with_role_fixture("own_data")
|
member_user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user)
|
|
||||||
|
|
||||||
# Router plug redirects non-admin users before LiveView loads
|
conn =
|
||||||
assert {:error, {:redirect, %{to: redirect_path, flash: %{"error" => error_message}}}} =
|
conn
|
||||||
|
|> MvWeb.ConnCase.conn_with_password_user(member_user)
|
||||||
|
|> put_locale_en()
|
||||||
|
|
||||||
|
assert {:error, {:redirect, %{to: redirect_path, flash: %{"error" => msg}}}} =
|
||||||
live(conn, ~p"/admin/import-export")
|
live(conn, ~p"/admin/import-export")
|
||||||
|
|
||||||
# Should redirect to user profile page
|
|
||||||
assert redirect_path =~ "/users/"
|
assert redirect_path =~ "/users/"
|
||||||
# Should show permission error in flash
|
assert msg =~ "don't have permission"
|
||||||
assert error_message =~ "don't have permission"
|
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
describe "CSV Import - Import" do
|
test "admin user can access page and run import", %{conn: conn} do
|
||||||
setup %{conn: conn} do
|
conn = put_locale_en(conn)
|
||||||
# Ensure admin user
|
|
||||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
|
||||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
|
|
||||||
|
|
||||||
# Read valid CSV fixture
|
|
||||||
csv_content =
|
csv_content =
|
||||||
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|
||||||
|> File.read!()
|
|> File.read!()
|
||||||
|
|
||||||
{:ok, conn: conn, admin_user: admin_user, csv_content: csv_content}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "admin can upload CSV and start import", %{conn: conn, csv_content: csv_content} do
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||||
|
|
||||||
# Simulate file upload using helper function
|
|
||||||
upload_csv_file(view, csv_content)
|
upload_csv_file(view, csv_content)
|
||||||
|
submit_import(view)
|
||||||
|
wait_for_import_completion()
|
||||||
|
|
||||||
# Trigger start_import event via form submit
|
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||||
assert view
|
assert has_element?(view, "[data-testid='import-summary']")
|
||||||
|> form("#csv-upload-form", %{})
|
|
||||||
|> render_submit()
|
|
||||||
|
|
||||||
# Check that import has started using data-testid
|
|
||||||
# Either import-progress-container exists (import started) OR we see a CSV error
|
|
||||||
html = render(view)
|
html = render(view)
|
||||||
import_started = has_element?(view, "[data-testid='import-progress-container']")
|
refute html =~ "Import aborted"
|
||||||
no_admin_error = not (html =~ "Only administrators can import")
|
assert html =~ "Successfully inserted"
|
||||||
|
|
||||||
# If import failed, it should be a CSV parsing error, not an admin error
|
# Business outcome: two members from fixture were created
|
||||||
if html =~ "Failed to prepare CSV import" do
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
# This is acceptable - CSV might have issues, but admin check passed
|
{:ok, members} = Membership.list_members(actor: system_actor)
|
||||||
assert no_admin_error
|
|
||||||
else
|
|
||||||
# Import should have started - check for progress container
|
|
||||||
assert import_started
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "admin import initializes progress correctly", %{conn: conn, csv_content: csv_content} do
|
imported =
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
Enum.filter(members, fn m ->
|
||||||
|
m.email in ["alice.smith@example.com", "bob.johnson@example.com"]
|
||||||
|
end)
|
||||||
|
|
||||||
# Simulate file upload using helper function
|
assert length(imported) == 2
|
||||||
upload_csv_file(view, csv_content)
|
|
||||||
|
|
||||||
view
|
|
||||||
|> form("#csv-upload-form", %{})
|
|
||||||
|> render_submit()
|
|
||||||
|
|
||||||
# Check that import has started using data-testid
|
|
||||||
html = render(view)
|
|
||||||
import_started = has_element?(view, "[data-testid='import-progress-container']")
|
|
||||||
no_admin_error = not (html =~ "Only administrators can import")
|
|
||||||
|
|
||||||
# If import failed, it should be a CSV parsing error, not an admin error
|
|
||||||
if html =~ "Failed to prepare CSV import" do
|
|
||||||
# This is acceptable - CSV might have issues, but admin check passed
|
|
||||||
assert no_admin_error
|
|
||||||
else
|
|
||||||
# Import should have started - check for progress container
|
|
||||||
assert import_started
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "non-admin cannot start import", %{conn: conn} do
|
|
||||||
# Member (own_data) user
|
|
||||||
member_user = Mv.Fixtures.user_with_role_fixture("own_data")
|
|
||||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user)
|
|
||||||
|
|
||||||
# Router plug redirects non-admin users before LiveView loads
|
|
||||||
assert {:error, {:redirect, %{to: redirect_path, flash: %{"error" => error_message}}}} =
|
|
||||||
live(conn, ~p"/admin/import-export")
|
|
||||||
|
|
||||||
# Should redirect to user profile page
|
|
||||||
assert redirect_path =~ "/users/"
|
|
||||||
# Should show permission error in flash
|
|
||||||
assert error_message =~ "don't have permission"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "invalid CSV shows user-friendly error", %{conn: conn} do
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
|
||||||
|
|
||||||
# Create invalid CSV (missing required fields)
|
|
||||||
invalid_csv = "invalid_header\nincomplete_row"
|
|
||||||
|
|
||||||
# Simulate file upload using helper function
|
|
||||||
upload_csv_file(view, invalid_csv, "invalid.csv")
|
|
||||||
|
|
||||||
view
|
|
||||||
|> form("#csv-upload-form", %{})
|
|
||||||
|> render_submit()
|
|
||||||
|
|
||||||
# Check for error message (flash)
|
|
||||||
html = render(view)
|
|
||||||
assert html =~ "error" or html =~ "failed" or html =~ "Failed to prepare"
|
|
||||||
end
|
|
||||||
|
|
||||||
@tag :skip
|
|
||||||
test "empty CSV shows error", %{conn: conn} do
|
|
||||||
# Skip this test - Phoenix LiveView has issues with empty file uploads in tests
|
|
||||||
# The error is handled correctly in production, but test framework has limitations
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
|
||||||
|
|
||||||
empty_csv = " "
|
|
||||||
csv_path = Path.join([System.tmp_dir!(), "empty_#{System.unique_integer()}.csv"])
|
|
||||||
File.write!(csv_path, empty_csv)
|
|
||||||
|
|
||||||
view
|
|
||||||
|> file_input("#csv-upload-form", :csv_file, [
|
|
||||||
%{
|
|
||||||
last_modified: System.system_time(:second),
|
|
||||||
name: "empty.csv",
|
|
||||||
content: empty_csv,
|
|
||||||
size: byte_size(empty_csv),
|
|
||||||
type: "text/csv"
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|> render_upload("empty.csv")
|
|
||||||
|
|
||||||
view
|
|
||||||
|> form("#csv-upload-form", %{})
|
|
||||||
|> render_submit()
|
|
||||||
|
|
||||||
# Check for error message
|
|
||||||
html = render(view)
|
|
||||||
assert html =~ "error" or html =~ "empty" or html =~ "failed" or html =~ "Failed to prepare"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "CSV Import - Step 3: Chunk Processing" do
|
# ---------- Business logic: Import behaviour (integration) ----------
|
||||||
|
describe "CSV Import - integration" do
|
||||||
setup %{conn: conn} do
|
setup %{conn: conn} do
|
||||||
# Ensure admin user
|
|
||||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
|
|
||||||
|
|
||||||
# Read valid CSV fixture
|
conn =
|
||||||
valid_csv_content =
|
conn
|
||||||
|
|> MvWeb.ConnCase.conn_with_password_user(admin_user)
|
||||||
|
|> put_locale_en()
|
||||||
|
|
||||||
|
valid_csv =
|
||||||
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|
||||||
|> File.read!()
|
|> File.read!()
|
||||||
|
|
||||||
# Read invalid CSV fixture
|
invalid_csv =
|
||||||
invalid_csv_content =
|
|
||||||
Path.join([__DIR__, "..", "..", "fixtures", "invalid_member_import.csv"])
|
Path.join([__DIR__, "..", "..", "fixtures", "invalid_member_import.csv"])
|
||||||
|> File.read!()
|
|> File.read!()
|
||||||
|
|
||||||
{:ok,
|
unknown_cf_csv =
|
||||||
conn: conn,
|
|
||||||
admin_user: admin_user,
|
|
||||||
valid_csv_content: valid_csv_content,
|
|
||||||
invalid_csv_content: invalid_csv_content}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "happy path: valid CSV processes all chunks and shows done status", %{
|
|
||||||
conn: conn,
|
|
||||||
valid_csv_content: csv_content
|
|
||||||
} do
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
|
||||||
|
|
||||||
# Simulate file upload using helper function
|
|
||||||
upload_csv_file(view, csv_content)
|
|
||||||
|
|
||||||
view
|
|
||||||
|> form("#csv-upload-form", %{})
|
|
||||||
|> render_submit()
|
|
||||||
|
|
||||||
# Wait for processing to complete
|
|
||||||
# In test mode, chunks are processed synchronously and messages are sent via send/2
|
|
||||||
# render(view) processes handle_info messages, so we call it multiple times
|
|
||||||
# to ensure all messages are processed
|
|
||||||
Process.sleep(1000)
|
|
||||||
|
|
||||||
# Check that import-results-panel exists (import completed)
|
|
||||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
|
||||||
|
|
||||||
# Verify success count is shown
|
|
||||||
html = render(view)
|
|
||||||
assert html =~ "Successfully inserted" or html =~ "inserted"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "error handling: invalid CSV shows errors with line numbers", %{
|
|
||||||
conn: conn,
|
|
||||||
invalid_csv_content: csv_content
|
|
||||||
} do
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
|
||||||
|
|
||||||
# Simulate file upload using helper function
|
|
||||||
upload_csv_file(view, csv_content, "invalid_import.csv")
|
|
||||||
|
|
||||||
view
|
|
||||||
|> form("#csv-upload-form", %{})
|
|
||||||
|> render_submit()
|
|
||||||
|
|
||||||
# Wait for chunk processing
|
|
||||||
Process.sleep(1000)
|
|
||||||
|
|
||||||
# Check that import-results-panel exists (import completed with errors)
|
|
||||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
|
||||||
|
|
||||||
# Check that error list exists
|
|
||||||
assert has_element?(view, "[data-testid='import-error-list']")
|
|
||||||
|
|
||||||
html = render(view)
|
|
||||||
# Should show failure count > 0
|
|
||||||
assert html =~ "failed" or html =~ "error" or html =~ "Failed"
|
|
||||||
|
|
||||||
# Should show line numbers in errors (from service, not recalculated)
|
|
||||||
# Line numbers should be 2, 3 (header is line 1)
|
|
||||||
assert html =~ "2" or html =~ "3" or html =~ "line"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "error cap: many failing rows caps errors at 50", %{conn: conn} do
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
|
||||||
|
|
||||||
# Generate CSV with 100 invalid rows (all missing email)
|
|
||||||
header = "first_name;last_name;email;street;postal_code;city\n"
|
|
||||||
invalid_rows = for i <- 1..100, do: "Row#{i};Last#{i};;Street#{i};12345;City#{i}\n"
|
|
||||||
large_invalid_csv = header <> Enum.join(invalid_rows)
|
|
||||||
|
|
||||||
# Simulate file upload using helper function
|
|
||||||
upload_csv_file(view, large_invalid_csv, "large_invalid.csv")
|
|
||||||
|
|
||||||
view
|
|
||||||
|> form("#csv-upload-form", %{})
|
|
||||||
|> render_submit()
|
|
||||||
|
|
||||||
# Wait for chunk processing
|
|
||||||
Process.sleep(1000)
|
|
||||||
|
|
||||||
# Check that import-results-panel exists (import completed)
|
|
||||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
|
||||||
|
|
||||||
html = render(view)
|
|
||||||
# Should show failed count == 100
|
|
||||||
assert html =~ "100" or html =~ "failed"
|
|
||||||
|
|
||||||
# Errors should be capped at 50 (but we can't easily check exact count in HTML)
|
|
||||||
# The important thing is that processing completes without crashing
|
|
||||||
# Import is done when import-results-panel exists
|
|
||||||
end
|
|
||||||
|
|
||||||
test "chunk scheduling: progress updates show chunk processing", %{
|
|
||||||
conn: conn,
|
|
||||||
valid_csv_content: csv_content
|
|
||||||
} do
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
|
||||||
|
|
||||||
# Simulate file upload using helper function
|
|
||||||
upload_csv_file(view, csv_content)
|
|
||||||
|
|
||||||
view
|
|
||||||
|> form("#csv-upload-form", %{})
|
|
||||||
|> render_submit()
|
|
||||||
|
|
||||||
# In test mode chunks run synchronously, so we may already be :done when we check.
|
|
||||||
# Accept either progress container (if we caught :running) or results panel (if already :done).
|
|
||||||
_html = render(view)
|
|
||||||
|
|
||||||
assert has_element?(view, "[data-testid='import-progress-container']") or
|
|
||||||
has_element?(view, "[data-testid='import-results-panel']")
|
|
||||||
|
|
||||||
# Wait for final state and assert results panel is shown
|
|
||||||
Process.sleep(500)
|
|
||||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "CSV Import - Step 4: Results UI" do
|
|
||||||
setup %{conn: conn} do
|
|
||||||
# Ensure admin user
|
|
||||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
|
||||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
|
|
||||||
|
|
||||||
# Read valid CSV fixture
|
|
||||||
valid_csv_content =
|
|
||||||
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|
|
||||||
|> File.read!()
|
|
||||||
|
|
||||||
# Read invalid CSV fixture
|
|
||||||
invalid_csv_content =
|
|
||||||
Path.join([__DIR__, "..", "..", "fixtures", "invalid_member_import.csv"])
|
|
||||||
|> File.read!()
|
|
||||||
|
|
||||||
# Read CSV with unknown custom field
|
|
||||||
unknown_custom_field_csv =
|
|
||||||
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_unknown_custom_field.csv"])
|
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_unknown_custom_field.csv"])
|
||||||
|> File.read!()
|
|> File.read!()
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
conn: conn,
|
conn: conn,
|
||||||
admin_user: admin_user,
|
valid_csv: valid_csv,
|
||||||
valid_csv_content: valid_csv_content,
|
invalid_csv: invalid_csv,
|
||||||
invalid_csv_content: invalid_csv_content,
|
unknown_custom_field_csv: unknown_cf_csv}
|
||||||
unknown_custom_field_csv: unknown_custom_field_csv}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "success rendering: valid CSV shows success count", %{
|
test "invalid CSV shows user-friendly prepare error", %{conn: conn} do
|
||||||
conn: conn,
|
|
||||||
valid_csv_content: csv_content
|
|
||||||
} do
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||||
|
upload_csv_file(view, "invalid_header\nincomplete_row", "invalid.csv")
|
||||||
# Simulate file upload using helper function
|
submit_import(view)
|
||||||
upload_csv_file(view, csv_content)
|
|
||||||
|
|
||||||
view
|
|
||||||
|> form("#csv-upload-form", %{})
|
|
||||||
|> render_submit()
|
|
||||||
|
|
||||||
# Wait for processing to complete
|
|
||||||
Process.sleep(1000)
|
|
||||||
|
|
||||||
# Check that import-results-panel exists (import completed)
|
|
||||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
|
||||||
|
|
||||||
# Verify success count is shown
|
|
||||||
html = render(view)
|
html = render(view)
|
||||||
assert html =~ "Successfully inserted" or html =~ "inserted"
|
assert html =~ "Failed to prepare CSV import"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "error rendering: invalid CSV shows failure count and error list with line numbers", %{
|
test "invalid rows show errors with correct CSV line numbers", %{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
invalid_csv_content: csv_content
|
invalid_csv: csv_content
|
||||||
} do
|
} do
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||||
|
|
||||||
# Simulate file upload using helper function
|
|
||||||
upload_csv_file(view, csv_content, "invalid_import.csv")
|
upload_csv_file(view, csv_content, "invalid_import.csv")
|
||||||
|
submit_import(view)
|
||||||
|
wait_for_import_completion()
|
||||||
|
|
||||||
view
|
|
||||||
|> form("#csv-upload-form", %{})
|
|
||||||
|> render_submit()
|
|
||||||
|
|
||||||
# Wait for processing
|
|
||||||
Process.sleep(1000)
|
|
||||||
|
|
||||||
# Check that import-results-panel exists (import completed with errors)
|
|
||||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||||
|
|
||||||
# Check that error list exists
|
|
||||||
assert has_element?(view, "[data-testid='import-error-list']")
|
assert has_element?(view, "[data-testid='import-error-list']")
|
||||||
|
|
||||||
html = render(view)
|
html = render(view)
|
||||||
# Should show failure count
|
assert html =~ "Failed"
|
||||||
assert html =~ "Failed" or html =~ "failed"
|
# Fixture has invalid email on line 2 and missing email on line 3
|
||||||
|
assert html =~ "Line 2"
|
||||||
# Should show error list with line numbers (from service, not recalculated)
|
assert html =~ "Line 3"
|
||||||
assert html =~ "Line" or html =~ "line" or html =~ "2" or html =~ "3"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "warning rendering: CSV with unknown custom field shows warnings block", %{
|
test "error list is capped and truncation message is shown", %{conn: conn} do
|
||||||
conn: conn,
|
|
||||||
unknown_custom_field_csv: csv_content
|
|
||||||
} do
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||||
|
header = "first_name;last_name;email;street;postal_code;city\n"
|
||||||
|
|
||||||
csv_path =
|
invalid_rows =
|
||||||
Path.join([System.tmp_dir!(), "unknown_custom_#{System.unique_integer()}.csv"])
|
for i <- 1..100, do: "Row#{i};Last#{i};;Street#{i};12345;City#{i}\n"
|
||||||
|
|
||||||
File.write!(csv_path, csv_content)
|
upload_csv_file(view, header <> Enum.join(invalid_rows), "large_invalid.csv")
|
||||||
|
submit_import(view)
|
||||||
|
wait_for_import_completion()
|
||||||
|
|
||||||
view
|
|
||||||
|> file_input("#csv-upload-form", :csv_file, [
|
|
||||||
%{
|
|
||||||
last_modified: System.system_time(:second),
|
|
||||||
name: "unknown_custom.csv",
|
|
||||||
content: csv_content,
|
|
||||||
size: byte_size(csv_content),
|
|
||||||
type: "text/csv"
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|> render_upload("unknown_custom.csv")
|
|
||||||
|
|
||||||
view
|
|
||||||
|> form("#csv-upload-form", %{})
|
|
||||||
|> render_submit()
|
|
||||||
|
|
||||||
# Wait for processing
|
|
||||||
Process.sleep(1000)
|
|
||||||
|
|
||||||
# Check that import-results-panel exists (import completed)
|
|
||||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||||
|
assert has_element?(view, "[data-testid='import-error-list']")
|
||||||
html = render(view)
|
html = render(view)
|
||||||
# Should show warnings block (if warnings were generated)
|
assert html =~ "100"
|
||||||
# Warnings are generated when unknown custom field columns are detected
|
assert html =~ "Error list truncated"
|
||||||
has_warnings = html =~ "Warning" or html =~ "warning" or html =~ "Warnings"
|
|
||||||
|
|
||||||
# If warnings exist, they should contain the column name
|
|
||||||
if has_warnings do
|
|
||||||
assert html =~ "UnknownCustomField" or html =~ "unknown" or html =~ "Unknown column" or
|
|
||||||
html =~ "will be ignored"
|
|
||||||
end
|
|
||||||
|
|
||||||
# Import should complete (either with or without warnings)
|
|
||||||
# Verified by import-results-panel existence above
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag :ui
|
test "row limit is enforced (1001 rows rejected)", %{conn: conn} do
|
||||||
test "A11y: file input has label", %{conn: conn} do
|
|
||||||
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
|
|
||||||
|
|
||||||
# Check for label associated with file input
|
|
||||||
assert html =~ ~r/<label[^>]*for=["']csv_file["']/i or
|
|
||||||
html =~ ~r/<label[^>]*>.*CSV File/i
|
|
||||||
end
|
|
||||||
|
|
||||||
@tag :ui
|
|
||||||
test "A11y: status/progress container has aria-live", %{conn: conn} do
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||||
|
|
||||||
html = render(view)
|
|
||||||
# Check for aria-live attribute in status area
|
|
||||||
assert html =~ ~r/aria-live=["']polite["']/i
|
|
||||||
end
|
|
||||||
|
|
||||||
@tag :ui
|
|
||||||
test "A11y: links have descriptive text", %{conn: conn} do
|
|
||||||
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
|
|
||||||
|
|
||||||
# Check that links have descriptive text (not just "click here")
|
|
||||||
# Template links should have text like "English Template" or "German Template"
|
|
||||||
assert html =~ "English Template" or html =~ "German Template" or
|
|
||||||
html =~ "English" or html =~ "German"
|
|
||||||
|
|
||||||
# Import page has link "Manage Member Data" and info text about "data field"
|
|
||||||
assert html =~ "Manage Member Data" or html =~ "data field" or html =~ "Data field"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "CSV Import - Step 5: Edge Cases" do
|
|
||||||
setup %{conn: conn} do
|
|
||||||
# Ensure admin user
|
|
||||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
|
||||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
|
|
||||||
|
|
||||||
{:ok, conn: conn, admin_user: admin_user}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "BOM + semicolon delimiter: import succeeds", %{conn: conn} do
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
|
||||||
|
|
||||||
# Read CSV with BOM
|
|
||||||
csv_content =
|
|
||||||
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_bom_semicolon.csv"])
|
|
||||||
|> File.read!()
|
|
||||||
|
|
||||||
# Simulate file upload using helper function
|
|
||||||
upload_csv_file(view, csv_content, "bom_import.csv")
|
|
||||||
|
|
||||||
view
|
|
||||||
|> form("#csv-upload-form", %{})
|
|
||||||
|> render_submit()
|
|
||||||
|
|
||||||
# Wait for processing
|
|
||||||
Process.sleep(1000)
|
|
||||||
|
|
||||||
# Check that import-results-panel exists (import completed successfully)
|
|
||||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
|
||||||
|
|
||||||
html = render(view)
|
|
||||||
# Should succeed (BOM is stripped automatically)
|
|
||||||
assert html =~ "Successfully inserted" or html =~ "inserted"
|
|
||||||
# Should not show error about BOM
|
|
||||||
refute html =~ "BOM" or html =~ "encoding"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "empty lines: line numbers in errors correspond to physical CSV lines", %{conn: conn} do
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
|
||||||
|
|
||||||
# CSV with empty line: header (line 1), valid row (line 2), empty (line 3), invalid (line 4)
|
|
||||||
csv_content =
|
|
||||||
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_empty_lines.csv"])
|
|
||||||
|> File.read!()
|
|
||||||
|
|
||||||
# Simulate file upload using helper function
|
|
||||||
upload_csv_file(view, csv_content, "empty_lines.csv")
|
|
||||||
|
|
||||||
view
|
|
||||||
|> form("#csv-upload-form", %{})
|
|
||||||
|> render_submit()
|
|
||||||
|
|
||||||
# Wait for processing
|
|
||||||
Process.sleep(1000)
|
|
||||||
|
|
||||||
html = render(view)
|
|
||||||
# Should show error with correct line number (line 4, not line 3)
|
|
||||||
# The error should be on the line with invalid email, which is after the empty line
|
|
||||||
assert html =~ "Line 4" or html =~ "line 4" or html =~ "4"
|
|
||||||
# Should show error message
|
|
||||||
assert html =~ "error" or html =~ "Error" or html =~ "invalid"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "too many rows (1001): import is rejected with user-friendly error", %{conn: conn} do
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
|
||||||
|
|
||||||
# Generate CSV with 1001 rows dynamically
|
|
||||||
header = "first_name;last_name;email;street;postal_code;city\n"
|
header = "first_name;last_name;email;street;postal_code;city\n"
|
||||||
|
|
||||||
rows =
|
rows =
|
||||||
|
|
@ -632,45 +161,122 @@ defmodule MvWeb.ImportExportLiveTest do
|
||||||
"Row#{i};Last#{i};email#{i}@example.com;Street#{i};12345;City#{i}\n"
|
"Row#{i};Last#{i};email#{i}@example.com;Street#{i};12345;City#{i}\n"
|
||||||
end
|
end
|
||||||
|
|
||||||
large_csv = header <> Enum.join(rows)
|
upload_csv_file(view, header <> Enum.join(rows), "too_many_rows.csv")
|
||||||
|
submit_import(view)
|
||||||
# Simulate file upload using helper function
|
|
||||||
upload_csv_file(view, large_csv, "too_many_rows.csv")
|
|
||||||
|
|
||||||
view
|
|
||||||
|> form("#csv-upload-form", %{})
|
|
||||||
|> render_submit()
|
|
||||||
|
|
||||||
html = render(view)
|
html = render(view)
|
||||||
# Should show user-friendly error about row limit
|
assert html =~ "exceeds"
|
||||||
assert html =~ "exceeds" or html =~ "maximum" or html =~ "limit" or html =~ "1000" or
|
|
||||||
html =~ "Failed to prepare"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag :ui
|
test "BOM and semicolon delimiter are accepted", %{conn: conn} do
|
||||||
test "wrong file type (.txt): upload shows error", %{conn: conn} do
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||||
|
|
||||||
# Create .txt file (not .csv)
|
csv_content =
|
||||||
txt_content = "This is not a CSV file\nJust some text\n"
|
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_bom_semicolon.csv"])
|
||||||
txt_path = Path.join([System.tmp_dir!(), "wrong_type_#{System.unique_integer()}.txt"])
|
|> File.read!()
|
||||||
File.write!(txt_path, txt_content)
|
|
||||||
|
|
||||||
# Try to upload .txt file
|
upload_csv_file(view, csv_content, "bom_import.csv")
|
||||||
# Note: allow_upload is configured to accept only .csv, so this should fail
|
submit_import(view)
|
||||||
# In tests, we can't easily simulate file type rejection, but we can check
|
wait_for_import_completion()
|
||||||
# that the UI shows appropriate help text
|
|
||||||
|
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||||
html = render(view)
|
html = render(view)
|
||||||
# Should show CSV-only restriction in help text
|
assert html =~ "Successfully inserted"
|
||||||
assert html =~ "CSV" or html =~ "csv" or html =~ ".csv"
|
refute html =~ "BOM"
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag :ui
|
test "physical line numbers in errors (empty line does not shift numbering)", %{
|
||||||
test "file input has correct accept attribute for CSV only", %{conn: conn} do
|
conn: conn
|
||||||
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
|
} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||||
|
|
||||||
# Check that file input has accept attribute for CSV
|
csv_content =
|
||||||
assert html =~ ~r/accept=["'][^"']*csv["']/i or html =~ "CSV files only"
|
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_empty_lines.csv"])
|
||||||
|
|> File.read!()
|
||||||
|
|
||||||
|
upload_csv_file(view, csv_content, "empty_lines.csv")
|
||||||
|
submit_import(view)
|
||||||
|
wait_for_import_completion()
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid='import-error-list']")
|
||||||
|
html = render(view)
|
||||||
|
# Invalid row is on physical line 4 (header, valid row, empty line, then invalid)
|
||||||
|
assert html =~ "Line 4"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unknown custom field column produces warnings", %{
|
||||||
|
conn: conn,
|
||||||
|
unknown_custom_field_csv: csv_content
|
||||||
|
} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||||
|
upload_csv_file(view, csv_content, "unknown_custom.csv")
|
||||||
|
submit_import(view)
|
||||||
|
wait_for_import_completion()
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||||
|
assert has_element?(view, "[data-testid='import-warnings']")
|
||||||
|
html = render(view)
|
||||||
|
assert html =~ "Warnings"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# ---------- UI (smoke / framework): tagged for exclusion from fast CI ----------
|
||||||
|
describe "Import/Export page UI" do
|
||||||
|
@describetag :ui
|
||||||
|
setup %{conn: conn} do
|
||||||
|
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> MvWeb.ConnCase.conn_with_password_user(admin_user)
|
||||||
|
|> put_locale_en()
|
||||||
|
|
||||||
|
{:ok, conn: conn}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "page loads and shows import form and export placeholder", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||||
|
assert has_element?(view, "[data-testid='csv-upload-form']")
|
||||||
|
assert has_element?(view, "[data-testid='start-import-button']")
|
||||||
|
assert has_element?(view, "[data-testid='custom-fields-link']")
|
||||||
|
html = render(view)
|
||||||
|
assert html =~ "Import Members (CSV)"
|
||||||
|
assert html =~ "Export Members (CSV)"
|
||||||
|
assert html =~ "Export functionality will be available"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "template links and file input are present", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||||
|
assert has_element?(view, "a[href*='/templates/member_import_en.csv']")
|
||||||
|
assert has_element?(view, "a[href*='/templates/member_import_de.csv']")
|
||||||
|
assert has_element?(view, "label[for='csv_file']")
|
||||||
|
assert has_element?(view, "#csv_file_help")
|
||||||
|
assert has_element?(view, "[data-testid='csv-upload-form'] input[type='file']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "after successful import, progress container has aria-live", %{conn: conn} do
|
||||||
|
csv_content =
|
||||||
|
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|
||||||
|
|> File.read!()
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||||
|
upload_csv_file(view, csv_content)
|
||||||
|
submit_import(view)
|
||||||
|
wait_for_import_completion()
|
||||||
|
assert has_element?(view, "[data-testid='import-progress-container']")
|
||||||
|
html = render(view)
|
||||||
|
assert html =~ "aria-live"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Skip: LiveView test harness does not reliably support empty/minimal file uploads.
|
||||||
|
# See docs/csv-member-import-v1.md (Issue #9).
|
||||||
|
@tag :skip
|
||||||
|
test "empty CSV shows error", %{conn: conn} do
|
||||||
|
conn = put_locale_en(conn)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||||
|
upload_csv_file(view, " ", "empty.csv")
|
||||||
|
submit_import(view)
|
||||||
|
html = render(view)
|
||||||
|
assert html =~ "Failed to prepare"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -225,7 +225,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
|> element("[data-testid='custom_field_#{field.id}']")
|
|> element("[data-testid='custom_field_#{field.id}']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc")
|
# Patch URL may include fields param (current field selection); assert sort outcome instead
|
||||||
|
assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='ascending']")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "NULL values and empty strings are always sorted last (ASC)", %{conn: conn} do
|
test "NULL values and empty strings are always sorted last (ASC)", %{conn: conn} do
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue