1201 lines
40 KiB
Elixir
1201 lines
40 KiB
Elixir
defmodule Mv.Membership.Import.MemberCSVTest do
|
|
use Mv.DataCase, async: true
|
|
use ExUnitProperties
|
|
|
|
alias Mv.Membership.Import.MemberCSV
|
|
|
|
describe "Error struct" do
|
|
test "Error struct exists with required fields" do
|
|
# This will fail at runtime if the struct doesn't exist
|
|
# We use struct/2 to create the struct at runtime
|
|
error =
|
|
struct(MemberCSV.Error, %{
|
|
csv_line_number: 5,
|
|
field: :email,
|
|
message: "is not a valid email"
|
|
})
|
|
|
|
assert error.csv_line_number == 5
|
|
assert error.field == :email
|
|
assert error.message == "is not a valid email"
|
|
end
|
|
|
|
test "Error struct allows nil field" do
|
|
# This will fail at runtime if the struct doesn't exist
|
|
error =
|
|
struct(MemberCSV.Error, %{
|
|
csv_line_number: 10,
|
|
field: nil,
|
|
message: "Row is empty"
|
|
})
|
|
|
|
assert error.csv_line_number == 10
|
|
assert error.field == nil
|
|
assert error.message == "Row is empty"
|
|
end
|
|
end
|
|
|
|
describe "prepare/2" do
|
|
test "accepts file_content and opts and returns tagged tuple" do
|
|
file_content = "email\njohn@example.com"
|
|
opts = []
|
|
|
|
result = MemberCSV.prepare(file_content, opts)
|
|
assert match?({:ok, _}, result) or match?({:error, _}, result)
|
|
end
|
|
|
|
test "returns {:ok, import_state} on success" do
|
|
file_content = "email\njohn@example.com"
|
|
opts = []
|
|
|
|
assert {:ok, import_state} = MemberCSV.prepare(file_content, opts)
|
|
|
|
# Check that import_state contains expected fields
|
|
assert Map.has_key?(import_state, :chunks)
|
|
assert Map.has_key?(import_state, :column_map)
|
|
assert Map.has_key?(import_state, :custom_field_map)
|
|
assert Map.has_key?(import_state, :warnings)
|
|
assert import_state.column_map[:email] == 0
|
|
assert import_state.chunks != []
|
|
end
|
|
|
|
test "returns {:error, reason} on failure" do
|
|
file_content = ""
|
|
opts = []
|
|
|
|
assert {:error, _reason} = MemberCSV.prepare(file_content, opts)
|
|
end
|
|
end
|
|
|
|
describe "process_chunk/4" do
|
|
setup do
|
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
%{actor: system_actor}
|
|
end
|
|
|
|
test "accepts chunk_rows_with_lines, column_map, custom_field_map, and opts and returns tagged tuple",
|
|
%{
|
|
actor: actor
|
|
} do
|
|
chunk_rows_with_lines = [{2, %{member: %{email: "john@example.com"}, custom: %{}}}]
|
|
column_map = %{email: 0}
|
|
custom_field_map = %{}
|
|
opts = [actor: actor]
|
|
|
|
result = MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
|
assert match?({:ok, _}, result) or match?({:error, _}, result)
|
|
end
|
|
|
|
test "creates member successfully with valid data", %{actor: actor} do
|
|
chunk_rows_with_lines = [
|
|
{2, %{member: %{email: "john@example.com", first_name: "John"}, custom: %{}}}
|
|
]
|
|
|
|
column_map = %{email: 0, first_name: 1}
|
|
custom_field_map = %{}
|
|
opts = [actor: actor]
|
|
|
|
assert {:ok, chunk_result} =
|
|
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
|
|
|
assert chunk_result.inserted == 1
|
|
assert chunk_result.failed == 0
|
|
assert chunk_result.errors == []
|
|
|
|
# Verify member was created
|
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
members = Mv.Membership.list_members!(actor: system_actor)
|
|
assert Enum.any?(members, &(&1.email == "john@example.com"))
|
|
end
|
|
|
|
test "returns error for invalid email", %{actor: actor} do
|
|
chunk_rows_with_lines = [
|
|
{2, %{member: %{email: "invalid-email"}, custom: %{}}}
|
|
]
|
|
|
|
column_map = %{email: 0}
|
|
custom_field_map = %{}
|
|
opts = [actor: actor]
|
|
|
|
assert {:ok, chunk_result} =
|
|
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
|
|
|
assert chunk_result.inserted == 0
|
|
assert chunk_result.failed == 1
|
|
assert length(chunk_result.errors) == 1
|
|
|
|
error = List.first(chunk_result.errors)
|
|
assert error.csv_line_number == 2
|
|
assert error.field == :email
|
|
# Error message should come from validate_row (Gettext-backed)
|
|
assert is_binary(error.message)
|
|
assert error.message != ""
|
|
end
|
|
|
|
test "returns error for missing email", %{actor: actor} do
|
|
chunk_rows_with_lines = [
|
|
{2, %{member: %{}, custom: %{}}}
|
|
]
|
|
|
|
column_map = %{}
|
|
custom_field_map = %{}
|
|
opts = [actor: actor]
|
|
|
|
assert {:ok, chunk_result} =
|
|
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
|
|
|
assert chunk_result.inserted == 0
|
|
assert chunk_result.failed == 1
|
|
assert length(chunk_result.errors) == 1
|
|
|
|
error = List.first(chunk_result.errors)
|
|
assert error.csv_line_number == 2
|
|
assert error.field == :email
|
|
assert is_binary(error.message)
|
|
end
|
|
|
|
test "returns error for whitespace-only email", %{actor: actor} do
|
|
chunk_rows_with_lines = [
|
|
{3, %{member: %{email: " "}, custom: %{}}}
|
|
]
|
|
|
|
column_map = %{email: 0}
|
|
custom_field_map = %{}
|
|
opts = [actor: actor]
|
|
|
|
assert {:ok, chunk_result} =
|
|
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
|
|
|
assert chunk_result.inserted == 0
|
|
assert chunk_result.failed == 1
|
|
assert length(chunk_result.errors) == 1
|
|
|
|
error = List.first(chunk_result.errors)
|
|
assert error.csv_line_number == 3
|
|
assert error.field == :email
|
|
end
|
|
|
|
test "returns error for duplicate email", %{actor: actor} do
|
|
# Create existing member first
|
|
{:ok, _existing} =
|
|
Mv.Membership.create_member(%{email: "duplicate@example.com", first_name: "Existing"},
|
|
actor: actor
|
|
)
|
|
|
|
chunk_rows_with_lines = [
|
|
{2, %{member: %{email: "duplicate@example.com", first_name: "New"}, custom: %{}}}
|
|
]
|
|
|
|
column_map = %{email: 0, first_name: 1}
|
|
custom_field_map = %{}
|
|
opts = [actor: actor]
|
|
|
|
assert {:ok, chunk_result} =
|
|
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
|
|
|
assert chunk_result.inserted == 0
|
|
assert chunk_result.failed == 1
|
|
assert length(chunk_result.errors) == 1
|
|
|
|
error = List.first(chunk_result.errors)
|
|
assert error.csv_line_number == 2
|
|
assert error.field == :email
|
|
assert error.message =~ "email" or error.message =~ "duplicate" or error.message =~ "unique"
|
|
end
|
|
|
|
test "creates member with custom field values", %{actor: actor} do
|
|
# Create custom field first
|
|
{:ok, custom_field} =
|
|
Mv.Membership.CustomField
|
|
|> Ash.Changeset.for_create(:create, %{
|
|
name: "Phone",
|
|
value_type: :string
|
|
})
|
|
|> Ash.create(actor: actor)
|
|
|
|
chunk_rows_with_lines = [
|
|
{2,
|
|
%{
|
|
member: %{email: "withcustom@example.com"},
|
|
custom: %{to_string(custom_field.id) => "123-456-7890"}
|
|
}}
|
|
]
|
|
|
|
column_map = %{email: 0}
|
|
custom_field_map = %{to_string(custom_field.id) => 1}
|
|
|
|
custom_field_lookup = %{
|
|
to_string(custom_field.id) => %{
|
|
id: custom_field.id,
|
|
value_type: custom_field.value_type,
|
|
name: custom_field.name
|
|
}
|
|
}
|
|
|
|
opts = [custom_field_lookup: custom_field_lookup, actor: actor]
|
|
|
|
assert {:ok, chunk_result} =
|
|
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
|
|
|
assert chunk_result.inserted == 1
|
|
assert chunk_result.failed == 0
|
|
|
|
# Verify member and custom field value were created
|
|
members = Mv.Membership.list_members!(actor: actor)
|
|
member = Enum.find(members, &(&1.email == "withcustom@example.com"))
|
|
assert member != nil
|
|
|
|
{:ok, member_with_cf} = Ash.load(member, :custom_field_values, actor: actor)
|
|
assert length(member_with_cf.custom_field_values) == 1
|
|
cfv = List.first(member_with_cf.custom_field_values)
|
|
assert cfv.custom_field_id == custom_field.id
|
|
assert cfv.value.value == "123-456-7890"
|
|
end
|
|
|
|
test "handles multiple rows with mixed success and failure", %{actor: actor} do
|
|
chunk_rows_with_lines = [
|
|
{2, %{member: %{email: "valid1@example.com"}, custom: %{}}},
|
|
{3, %{member: %{email: "invalid-email"}, custom: %{}}},
|
|
{4, %{member: %{email: "valid2@example.com"}, custom: %{}}}
|
|
]
|
|
|
|
column_map = %{email: 0}
|
|
custom_field_map = %{}
|
|
opts = [actor: actor]
|
|
|
|
assert {:ok, chunk_result} =
|
|
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
|
|
|
assert chunk_result.inserted == 2
|
|
assert chunk_result.failed == 1
|
|
assert length(chunk_result.errors) == 1
|
|
|
|
error = List.first(chunk_result.errors)
|
|
assert error.csv_line_number == 3
|
|
assert error.field == :email
|
|
# Error should come from validate_row, not from DB insert
|
|
assert is_binary(error.message)
|
|
end
|
|
|
|
test "preserves CSV line numbers in errors", %{actor: actor} do
|
|
chunk_rows_with_lines = [
|
|
{5, %{member: %{email: "invalid"}, custom: %{}}},
|
|
{10, %{member: %{email: "also-invalid"}, custom: %{}}}
|
|
]
|
|
|
|
column_map = %{email: 0}
|
|
custom_field_map = %{}
|
|
opts = [actor: actor]
|
|
|
|
assert {:ok, chunk_result} =
|
|
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
|
|
|
assert chunk_result.failed == 2
|
|
assert length(chunk_result.errors) == 2
|
|
|
|
line_numbers = Enum.map(chunk_result.errors, & &1.csv_line_number)
|
|
assert 5 in line_numbers
|
|
assert 10 in line_numbers
|
|
end
|
|
|
|
test "returns {:ok, chunk_result} on success", %{actor: actor} do
|
|
chunk_rows_with_lines = [{2, %{member: %{email: "test@example.com"}, custom: %{}}}]
|
|
column_map = %{email: 0}
|
|
custom_field_map = %{}
|
|
opts = [actor: actor]
|
|
|
|
assert {:ok, chunk_result} =
|
|
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
|
|
|
# Check that chunk_result contains expected fields
|
|
assert Map.has_key?(chunk_result, :inserted)
|
|
assert Map.has_key?(chunk_result, :failed)
|
|
assert Map.has_key?(chunk_result, :errors)
|
|
assert is_integer(chunk_result.inserted)
|
|
assert is_integer(chunk_result.failed)
|
|
assert is_list(chunk_result.errors)
|
|
end
|
|
|
|
test "returns {:ok, _} with zero counts for empty chunk", %{actor: actor} do
|
|
chunk_rows_with_lines = []
|
|
column_map = %{}
|
|
custom_field_map = %{}
|
|
opts = [actor: actor]
|
|
|
|
assert {:ok, chunk_result} =
|
|
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
|
|
|
assert chunk_result.inserted == 0
|
|
assert chunk_result.failed == 0
|
|
assert chunk_result.errors == []
|
|
end
|
|
|
|
test "error capping collects exactly 50 errors", %{actor: actor} do
|
|
# Create 50 rows with invalid emails
|
|
chunk_rows_with_lines =
|
|
1..50
|
|
|> Enum.map(fn i ->
|
|
{i + 1, %{member: %{email: "invalid-email-#{i}"}, custom: %{}}}
|
|
end)
|
|
|
|
column_map = %{email: 0}
|
|
custom_field_map = %{}
|
|
opts = [existing_error_count: 0, max_errors: 50, actor: actor]
|
|
|
|
assert {:ok, chunk_result} =
|
|
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
|
|
|
assert chunk_result.inserted == 0
|
|
assert chunk_result.failed == 50
|
|
assert length(chunk_result.errors) == 50
|
|
end
|
|
|
|
test "error capping collects only first 50 errors when more than 50 errors occur", %{
|
|
actor: actor
|
|
} do
|
|
# Create 60 rows with invalid emails
|
|
chunk_rows_with_lines =
|
|
1..60
|
|
|> Enum.map(fn i ->
|
|
{i + 1, %{member: %{email: "invalid-email-#{i}"}, custom: %{}}}
|
|
end)
|
|
|
|
column_map = %{email: 0}
|
|
custom_field_map = %{}
|
|
opts = [existing_error_count: 0, max_errors: 50, actor: actor]
|
|
|
|
assert {:ok, chunk_result} =
|
|
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
|
|
|
assert chunk_result.inserted == 0
|
|
assert chunk_result.failed == 60
|
|
assert length(chunk_result.errors) == 50
|
|
end
|
|
|
|
test "error capping respects existing_error_count", %{actor: actor} do
|
|
# Create 30 rows with invalid emails
|
|
chunk_rows_with_lines =
|
|
1..30
|
|
|> Enum.map(fn i ->
|
|
{i + 1, %{member: %{email: "invalid-email-#{i}"}, custom: %{}}}
|
|
end)
|
|
|
|
column_map = %{email: 0}
|
|
custom_field_map = %{}
|
|
opts = [existing_error_count: 25, max_errors: 50, actor: actor]
|
|
|
|
assert {:ok, chunk_result} =
|
|
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
|
|
|
assert chunk_result.inserted == 0
|
|
assert chunk_result.failed == 30
|
|
# Should only collect 25 errors (25 existing + 25 new = 50 limit)
|
|
assert length(chunk_result.errors) == 25
|
|
end
|
|
|
|
test "error capping collects no errors when limit already reached", %{actor: actor} do
|
|
# Create 10 rows with invalid emails
|
|
chunk_rows_with_lines =
|
|
1..10
|
|
|> Enum.map(fn i ->
|
|
{i + 1, %{member: %{email: "invalid-email-#{i}"}, custom: %{}}}
|
|
end)
|
|
|
|
column_map = %{email: 0}
|
|
custom_field_map = %{}
|
|
opts = [existing_error_count: 50, max_errors: 50, actor: actor]
|
|
|
|
assert {:ok, chunk_result} =
|
|
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
|
|
|
assert chunk_result.inserted == 0
|
|
assert chunk_result.failed == 10
|
|
assert chunk_result.errors == []
|
|
end
|
|
|
|
test "error capping with mixed success and failure", %{actor: actor} do
|
|
# Create 100 rows: 30 valid, 70 invalid
|
|
valid_rows =
|
|
1..30
|
|
|> Enum.map(fn i ->
|
|
{i + 1, %{member: %{email: "valid#{i}@example.com"}, custom: %{}}}
|
|
end)
|
|
|
|
invalid_rows =
|
|
31..100
|
|
|> Enum.map(fn i ->
|
|
{i + 1, %{member: %{email: "invalid-email-#{i}"}, custom: %{}}}
|
|
end)
|
|
|
|
chunk_rows_with_lines = valid_rows ++ invalid_rows
|
|
|
|
column_map = %{email: 0}
|
|
custom_field_map = %{}
|
|
opts = [existing_error_count: 0, max_errors: 50, actor: actor]
|
|
|
|
assert {:ok, chunk_result} =
|
|
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
|
|
|
assert chunk_result.inserted == 30
|
|
assert chunk_result.failed == 70
|
|
# Should only collect 50 errors (limit reached)
|
|
assert length(chunk_result.errors) == 50
|
|
end
|
|
|
|
test "error capping with custom max_errors", %{actor: actor} do
|
|
# Create 20 rows with invalid emails
|
|
chunk_rows_with_lines =
|
|
1..20
|
|
|> Enum.map(fn i ->
|
|
{i + 1, %{member: %{email: "invalid-email-#{i}"}, custom: %{}}}
|
|
end)
|
|
|
|
column_map = %{email: 0}
|
|
custom_field_map = %{}
|
|
opts = [existing_error_count: 0, max_errors: 10, actor: actor]
|
|
|
|
assert {:ok, chunk_result} =
|
|
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
|
|
|
assert chunk_result.inserted == 0
|
|
assert chunk_result.failed == 20
|
|
assert length(chunk_result.errors) == 10
|
|
end
|
|
end
|
|
|
|
describe "validate_row/3" do
|
|
test "returns error when email is missing" do
|
|
row_map = %{member: %{}, custom: %{}}
|
|
csv_line_number = 5
|
|
opts = []
|
|
|
|
assert {:error, error} = MemberCSV.validate_row(row_map, csv_line_number, opts)
|
|
assert %MemberCSV.Error{} = error
|
|
assert error.csv_line_number == 5
|
|
assert error.field == :email
|
|
assert error.message != nil
|
|
assert error.message != ""
|
|
end
|
|
|
|
test "returns error when email is only whitespace" do
|
|
row_map = %{member: %{email: " "}, custom: %{}}
|
|
csv_line_number = 3
|
|
opts = []
|
|
|
|
assert {:error, error} = MemberCSV.validate_row(row_map, csv_line_number, opts)
|
|
assert %MemberCSV.Error{} = error
|
|
assert error.csv_line_number == 3
|
|
assert error.field == :email
|
|
assert error.message != nil
|
|
end
|
|
|
|
test "returns error when email is nil" do
|
|
row_map = %{member: %{email: nil}, custom: %{}}
|
|
csv_line_number = 7
|
|
opts = []
|
|
|
|
assert {:error, error} = MemberCSV.validate_row(row_map, csv_line_number, opts)
|
|
assert %MemberCSV.Error{} = error
|
|
assert error.csv_line_number == 7
|
|
assert error.field == :email
|
|
end
|
|
|
|
test "returns error when email format is invalid" do
|
|
row_map = %{member: %{email: "invalid-email"}, custom: %{}}
|
|
csv_line_number = 4
|
|
opts = []
|
|
|
|
assert {:error, error} = MemberCSV.validate_row(row_map, csv_line_number, opts)
|
|
assert %MemberCSV.Error{} = error
|
|
assert error.csv_line_number == 4
|
|
assert error.field == :email
|
|
assert error.message != nil
|
|
end
|
|
|
|
test "returns {:ok, trimmed_row_map} when email is valid with whitespace" do
|
|
row_map = %{member: %{email: " john@example.com "}, custom: %{}}
|
|
csv_line_number = 2
|
|
opts = []
|
|
|
|
assert {:ok, trimmed_row_map} = MemberCSV.validate_row(row_map, csv_line_number, opts)
|
|
assert trimmed_row_map.member.email == "john@example.com"
|
|
end
|
|
|
|
test "returns {:ok, trimmed_row_map} when email is valid without whitespace" do
|
|
row_map = %{member: %{email: "john@example.com"}, custom: %{}}
|
|
csv_line_number = 2
|
|
opts = []
|
|
|
|
assert {:ok, trimmed_row_map} = MemberCSV.validate_row(row_map, csv_line_number, opts)
|
|
assert trimmed_row_map.member.email == "john@example.com"
|
|
end
|
|
|
|
test "trims all string values in member map" do
|
|
row_map = %{
|
|
member: %{
|
|
email: " john@example.com ",
|
|
first_name: " John ",
|
|
last_name: " Doe "
|
|
},
|
|
custom: %{}
|
|
}
|
|
|
|
csv_line_number = 2
|
|
opts = []
|
|
|
|
assert {:ok, trimmed_row_map} = MemberCSV.validate_row(row_map, csv_line_number, opts)
|
|
assert trimmed_row_map.member.email == "john@example.com"
|
|
assert trimmed_row_map.member.first_name == "John"
|
|
assert trimmed_row_map.member.last_name == "Doe"
|
|
end
|
|
|
|
test "preserves custom map unchanged" do
|
|
row_map = %{
|
|
member: %{email: "john@example.com"},
|
|
custom: %{"field1" => "value1"}
|
|
}
|
|
|
|
csv_line_number = 2
|
|
opts = []
|
|
|
|
assert {:ok, trimmed_row_map} = MemberCSV.validate_row(row_map, csv_line_number, opts)
|
|
assert trimmed_row_map.custom == %{"field1" => "value1"}
|
|
end
|
|
|
|
test "uses Gettext for error messages" do
|
|
row_map = %{member: %{}, custom: %{}}
|
|
csv_line_number = 5
|
|
opts = []
|
|
|
|
# Test with default locale (should work)
|
|
assert {:error, error} = MemberCSV.validate_row(row_map, csv_line_number, opts)
|
|
assert is_binary(error.message)
|
|
|
|
# Test with German locale
|
|
Gettext.put_locale(MvWeb.Gettext, "de")
|
|
assert {:error, error_de} = MemberCSV.validate_row(row_map, csv_line_number, opts)
|
|
assert is_binary(error_de.message)
|
|
|
|
# Test with English locale
|
|
Gettext.put_locale(MvWeb.Gettext, "en")
|
|
assert {:error, error_en} = MemberCSV.validate_row(row_map, csv_line_number, opts)
|
|
assert is_binary(error_en.message)
|
|
|
|
# Reset to default
|
|
Gettext.put_locale(MvWeb.Gettext, "en")
|
|
end
|
|
|
|
test "handles empty opts gracefully" do
|
|
row_map = %{member: %{email: "john@example.com"}, custom: %{}}
|
|
csv_line_number = 2
|
|
opts = []
|
|
|
|
assert {:ok, _} = MemberCSV.validate_row(row_map, csv_line_number, opts)
|
|
end
|
|
|
|
test "handles missing member key gracefully" do
|
|
row_map = %{custom: %{}}
|
|
csv_line_number = 3
|
|
opts = []
|
|
|
|
assert {:error, error} = MemberCSV.validate_row(row_map, csv_line_number, opts)
|
|
assert %MemberCSV.Error{} = error
|
|
assert error.csv_line_number == 3
|
|
end
|
|
end
|
|
|
|
describe "custom field import" do
|
|
setup do
|
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
%{actor: system_actor}
|
|
end
|
|
|
|
test "creates member with valid integer custom field value", %{actor: actor} do
|
|
# Create integer custom field
|
|
{:ok, custom_field} =
|
|
Mv.Membership.CustomField
|
|
|> Ash.Changeset.for_create(:create, %{
|
|
name: "Alter",
|
|
value_type: :integer
|
|
})
|
|
|> Ash.create(actor: actor)
|
|
|
|
chunk_rows_with_lines = [
|
|
{2,
|
|
%{
|
|
member: %{email: "withage@example.com"},
|
|
custom: %{to_string(custom_field.id) => "25"}
|
|
}}
|
|
]
|
|
|
|
column_map = %{email: 0}
|
|
custom_field_map = %{to_string(custom_field.id) => 1}
|
|
|
|
custom_field_lookup = %{
|
|
to_string(custom_field.id) => %{
|
|
id: custom_field.id,
|
|
value_type: custom_field.value_type,
|
|
name: custom_field.name
|
|
}
|
|
}
|
|
|
|
opts = [custom_field_lookup: custom_field_lookup, actor: actor]
|
|
|
|
assert {:ok, chunk_result} =
|
|
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
|
|
|
assert chunk_result.inserted == 1
|
|
assert chunk_result.failed == 0
|
|
|
|
# Verify member and custom field value were created
|
|
members = Mv.Membership.list_members!(actor: actor)
|
|
member = Enum.find(members, &(&1.email == "withage@example.com"))
|
|
assert member != nil
|
|
|
|
{:ok, member_with_cf} = Ash.load(member, :custom_field_values, actor: actor)
|
|
assert length(member_with_cf.custom_field_values) == 1
|
|
cfv = List.first(member_with_cf.custom_field_values)
|
|
assert cfv.custom_field_id == custom_field.id
|
|
assert cfv.value.value == 25
|
|
assert cfv.value.type == :integer
|
|
end
|
|
|
|
test "returns error for invalid integer custom field value", %{actor: actor} do
|
|
# Create integer custom field
|
|
{:ok, custom_field} =
|
|
Mv.Membership.CustomField
|
|
|> Ash.Changeset.for_create(:create, %{
|
|
name: "Alter",
|
|
value_type: :integer
|
|
})
|
|
|> Ash.create(actor: actor)
|
|
|
|
chunk_rows_with_lines = [
|
|
{2,
|
|
%{
|
|
member: %{email: "invalidage@example.com"},
|
|
custom: %{to_string(custom_field.id) => "abc"}
|
|
}}
|
|
]
|
|
|
|
column_map = %{email: 0}
|
|
custom_field_map = %{to_string(custom_field.id) => 1}
|
|
|
|
custom_field_lookup = %{
|
|
to_string(custom_field.id) => %{
|
|
id: custom_field.id,
|
|
value_type: custom_field.value_type,
|
|
name: custom_field.name
|
|
}
|
|
}
|
|
|
|
opts = [custom_field_lookup: custom_field_lookup, actor: actor]
|
|
|
|
assert {:ok, chunk_result} =
|
|
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
|
|
|
assert chunk_result.inserted == 0
|
|
assert chunk_result.failed == 1
|
|
assert length(chunk_result.errors) == 1
|
|
|
|
error = List.first(chunk_result.errors)
|
|
assert error.csv_line_number == 2
|
|
assert error.message =~ "custom_field: Alter"
|
|
assert error.message =~ "Number"
|
|
assert error.message =~ "abc"
|
|
end
|
|
|
|
test "returns error for invalid date custom field value", %{actor: actor} do
|
|
# Create date custom field
|
|
{:ok, custom_field} =
|
|
Mv.Membership.CustomField
|
|
|> Ash.Changeset.for_create(:create, %{
|
|
name: "Geburtstag",
|
|
value_type: :date
|
|
})
|
|
|> Ash.create(actor: actor)
|
|
|
|
chunk_rows_with_lines = [
|
|
{3,
|
|
%{
|
|
member: %{email: "invaliddate@example.com"},
|
|
custom: %{to_string(custom_field.id) => "not-a-date"}
|
|
}}
|
|
]
|
|
|
|
column_map = %{email: 0}
|
|
custom_field_map = %{to_string(custom_field.id) => 1}
|
|
|
|
custom_field_lookup = %{
|
|
to_string(custom_field.id) => %{
|
|
id: custom_field.id,
|
|
value_type: custom_field.value_type,
|
|
name: custom_field.name
|
|
}
|
|
}
|
|
|
|
opts = [custom_field_lookup: custom_field_lookup, actor: actor]
|
|
|
|
assert {:ok, chunk_result} =
|
|
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
|
|
|
assert chunk_result.inserted == 0
|
|
assert chunk_result.failed == 1
|
|
assert length(chunk_result.errors) == 1
|
|
|
|
error = List.first(chunk_result.errors)
|
|
assert error.csv_line_number == 3
|
|
assert error.message =~ "custom_field: Geburtstag"
|
|
assert error.message =~ "Date"
|
|
end
|
|
|
|
test "returns error for invalid email custom field value", %{actor: actor} do
|
|
# Create email custom field
|
|
{:ok, custom_field} =
|
|
Mv.Membership.CustomField
|
|
|> Ash.Changeset.for_create(:create, %{
|
|
name: "Work Email",
|
|
value_type: :email
|
|
})
|
|
|> Ash.create(actor: actor)
|
|
|
|
chunk_rows_with_lines = [
|
|
{4,
|
|
%{
|
|
member: %{email: "invalidemailcf@example.com"},
|
|
custom: %{to_string(custom_field.id) => "not-an-email"}
|
|
}}
|
|
]
|
|
|
|
column_map = %{email: 0}
|
|
custom_field_map = %{to_string(custom_field.id) => 1}
|
|
|
|
custom_field_lookup = %{
|
|
to_string(custom_field.id) => %{
|
|
id: custom_field.id,
|
|
value_type: custom_field.value_type,
|
|
name: custom_field.name
|
|
}
|
|
}
|
|
|
|
opts = [custom_field_lookup: custom_field_lookup, actor: actor]
|
|
|
|
assert {:ok, chunk_result} =
|
|
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
|
|
|
assert chunk_result.inserted == 0
|
|
assert chunk_result.failed == 1
|
|
assert length(chunk_result.errors) == 1
|
|
|
|
error = List.first(chunk_result.errors)
|
|
assert error.csv_line_number == 4
|
|
assert error.message =~ "custom_field: Work Email"
|
|
assert error.message =~ "E-Mail"
|
|
end
|
|
|
|
test "returns error for invalid boolean custom field value", %{actor: actor} do
|
|
# Create boolean custom field
|
|
{:ok, custom_field} =
|
|
Mv.Membership.CustomField
|
|
|> Ash.Changeset.for_create(:create, %{
|
|
name: "Is Active",
|
|
value_type: :boolean
|
|
})
|
|
|> Ash.create(actor: actor)
|
|
|
|
chunk_rows_with_lines = [
|
|
{5,
|
|
%{
|
|
member: %{email: "invalidbool@example.com"},
|
|
custom: %{to_string(custom_field.id) => "maybe"}
|
|
}}
|
|
]
|
|
|
|
column_map = %{email: 0}
|
|
custom_field_map = %{to_string(custom_field.id) => 1}
|
|
|
|
custom_field_lookup = %{
|
|
to_string(custom_field.id) => %{
|
|
id: custom_field.id,
|
|
value_type: custom_field.value_type,
|
|
name: custom_field.name
|
|
}
|
|
}
|
|
|
|
opts = [custom_field_lookup: custom_field_lookup, actor: actor]
|
|
|
|
assert {:ok, chunk_result} =
|
|
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
|
|
|
|
assert chunk_result.inserted == 0
|
|
assert chunk_result.failed == 1
|
|
assert length(chunk_result.errors) == 1
|
|
|
|
error = List.first(chunk_result.errors)
|
|
assert error.csv_line_number == 5
|
|
assert error.message =~ "custom_field: Is Active"
|
|
# Error message should indicate boolean/Yes-No validation failure
|
|
assert String.contains?(error.message, "Yes/No") ||
|
|
String.contains?(error.message, "true/false") ||
|
|
String.contains?(error.message, "boolean")
|
|
end
|
|
end
|
|
|
|
describe "prepare/2 with custom fields" do
|
|
setup do
|
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
|
|
# Create a custom field
|
|
{:ok, custom_field} =
|
|
Mv.Membership.CustomField
|
|
|> Ash.Changeset.for_create(:create, %{
|
|
name: "Membership Number",
|
|
value_type: :string
|
|
})
|
|
|> Ash.create(actor: system_actor)
|
|
|
|
%{actor: system_actor, custom_field: custom_field}
|
|
end
|
|
|
|
test "includes custom field in custom_field_map when header matches", %{
|
|
custom_field: custom_field
|
|
} do
|
|
# CSV with custom field column
|
|
csv_content = "email;Membership Number\njohn@example.com;12345"
|
|
|
|
assert {:ok, import_state} = MemberCSV.prepare(csv_content)
|
|
|
|
# Check that custom field is mapped
|
|
assert Map.has_key?(import_state.custom_field_map, to_string(custom_field.id))
|
|
assert import_state.column_map[:email] == 0
|
|
end
|
|
|
|
test "includes warning for unknown custom field column", %{custom_field: _custom_field} do
|
|
# CSV with unknown custom field column (not matching any existing custom field)
|
|
csv_content = "email;NichtExistierend\njohn@example.com;value"
|
|
|
|
assert {:ok, import_state} = MemberCSV.prepare(csv_content)
|
|
|
|
# Check that warning is present
|
|
assert import_state.warnings != []
|
|
warning = List.first(import_state.warnings)
|
|
assert warning =~ "NichtExistierend"
|
|
assert warning =~ "ignored"
|
|
assert warning =~ "custom field"
|
|
|
|
# Check that unknown column is not in custom_field_map
|
|
assert import_state.custom_field_map == %{}
|
|
# Member import should still succeed
|
|
assert import_state.column_map[:email] == 0
|
|
end
|
|
|
|
test "import succeeds even with unknown custom field columns", %{custom_field: _custom_field} do
|
|
# CSV with unknown custom field column
|
|
csv_content = "email;UnknownField\njohn@example.com;value"
|
|
|
|
assert {:ok, import_state} = MemberCSV.prepare(csv_content)
|
|
|
|
# Import state should be valid
|
|
assert import_state.column_map[:email] == 0
|
|
assert import_state.chunks != []
|
|
end
|
|
end
|
|
|
|
describe "prepare/2 column resolution integration" do
|
|
setup do
|
|
%{actor: Mv.Helpers.SystemActor.get_system_actor()}
|
|
end
|
|
|
|
test "exposes resolver output keys in import_state", %{actor: actor} do
|
|
csv_content = "email\njohn@example.com"
|
|
|
|
assert {:ok, import_state} = MemberCSV.prepare(csv_content, actor: actor)
|
|
|
|
assert Map.has_key?(import_state, :ignored)
|
|
assert Map.has_key?(import_state, :groups_to_create)
|
|
assert Map.has_key?(import_state, :fee_type_map)
|
|
assert Map.has_key?(import_state, :fee_type_warnings)
|
|
assert Map.has_key?(import_state, :has_empty_fee_type_cells?)
|
|
assert Map.has_key?(import_state, :preview_rows)
|
|
end
|
|
|
|
test "fee-status column is reported as ignored, not as a custom field", %{actor: actor} do
|
|
{:ok, _custom_field} =
|
|
Mv.Membership.CustomField
|
|
|> Ash.Changeset.for_create(:create, %{name: "Bezahlstatus", value_type: :string})
|
|
|> Ash.create(actor: actor)
|
|
|
|
csv_content = "email;Bezahlstatus\njohn@example.com;paid"
|
|
|
|
assert {:ok, import_state} = MemberCSV.prepare(csv_content, actor: actor)
|
|
|
|
assert import_state.ignored == ["Bezahlstatus"]
|
|
assert import_state.custom_field_map == %{}
|
|
end
|
|
|
|
test "preview rows are limited to 3", %{actor: actor} do
|
|
csv_content = "email\na@example.com\nb@example.com\nc@example.com\nd@example.com"
|
|
|
|
assert {:ok, import_state} = MemberCSV.prepare(csv_content, actor: actor)
|
|
|
|
assert length(import_state.preview_rows) == 3
|
|
end
|
|
end
|
|
|
|
describe "process_chunk/4 fee-type assignment" do
|
|
setup do
|
|
actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
|
|
{:ok, fee_type} =
|
|
Mv.MembershipFees.create_membership_fee_type(
|
|
%{name: "Premium", amount: Decimal.new("25.00"), interval: :yearly},
|
|
actor: actor
|
|
)
|
|
|
|
%{actor: actor, fee_type: fee_type}
|
|
end
|
|
|
|
test "sets membership_fee_type_id when fee-type cell matches a known type", %{
|
|
actor: actor,
|
|
fee_type: fee_type
|
|
} do
|
|
chunk = [
|
|
{2, %{member: %{email: "fee-known@example.com"}, custom: %{}, fee_type: "Premium"}}
|
|
]
|
|
|
|
opts = [
|
|
actor: actor,
|
|
fee_type_map: %{"premium" => fee_type.id}
|
|
]
|
|
|
|
assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts)
|
|
assert result.inserted == 1
|
|
|
|
member =
|
|
Mv.Membership.list_members!(actor: actor)
|
|
|> Enum.find(&(&1.email == "fee-known@example.com"))
|
|
|
|
assert member.membership_fee_type_id == fee_type.id
|
|
end
|
|
|
|
test "adds a warning when the fee-type name is unknown", %{actor: actor} do
|
|
chunk = [
|
|
{2, %{member: %{email: "fee-unknown@example.com"}, custom: %{}, fee_type: "Ghost Type"}}
|
|
]
|
|
|
|
opts = [actor: actor, fee_type_map: %{}]
|
|
|
|
assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts)
|
|
assert result.inserted == 1
|
|
assert Enum.any?(result.warnings, &(&1 =~ "Ghost Type"))
|
|
end
|
|
|
|
test "uses the default fee type when the fee-type cell is empty", %{
|
|
actor: actor,
|
|
fee_type: fee_type
|
|
} do
|
|
{:ok, settings} = Mv.Membership.get_settings()
|
|
|
|
{:ok, _settings} =
|
|
Mv.Membership.update_settings(
|
|
settings,
|
|
%{default_membership_fee_type_id: fee_type.id},
|
|
actor: actor
|
|
)
|
|
|
|
chunk = [{2, %{member: %{email: "fee-empty@example.com"}, custom: %{}, fee_type: ""}}]
|
|
|
|
opts = [actor: actor, fee_type_map: %{}]
|
|
|
|
assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts)
|
|
assert result.inserted == 1
|
|
|
|
member =
|
|
Mv.Membership.list_members!(actor: actor)
|
|
|> Enum.find(&(&1.email == "fee-empty@example.com"))
|
|
|
|
# Default fee type assigned via SetDefaultMembershipFeeType.
|
|
assert member.membership_fee_type_id == fee_type.id
|
|
end
|
|
end
|
|
|
|
describe "process_chunk/4 group assignment" do
|
|
setup do
|
|
%{actor: Mv.Helpers.SystemActor.get_system_actor()}
|
|
end
|
|
|
|
defp group_names_for(email, actor) do
|
|
member =
|
|
Mv.Membership.list_members!(actor: actor)
|
|
|> Enum.find(&(&1.email == email))
|
|
|
|
member = Ash.load!(member, :groups, actor: actor)
|
|
member.groups |> Enum.map(& &1.name) |> Enum.sort()
|
|
end
|
|
|
|
test "assigns member to an existing group", %{actor: actor} do
|
|
existing = Mv.Fixtures.group_fixture(%{name: "Orchester"})
|
|
|
|
chunk = [
|
|
{2, %{member: %{email: "g-existing@example.com"}, custom: %{}, groups: "Orchester"}}
|
|
]
|
|
|
|
opts = [actor: actor, groups_found: [existing]]
|
|
|
|
assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts)
|
|
assert result.inserted == 1
|
|
|
|
assert group_names_for("g-existing@example.com", actor) == ["Orchester"]
|
|
|
|
# No new group was created.
|
|
orchester = Enum.filter(Mv.Membership.list_groups!(actor: actor), &(&1.name == "Orchester"))
|
|
assert length(orchester) == 1
|
|
end
|
|
|
|
test "auto-creates an unknown group and assigns the member", %{actor: actor} do
|
|
chunk = [
|
|
{2, %{member: %{email: "g-new@example.com"}, custom: %{}, groups: "Frische Gruppe"}}
|
|
]
|
|
|
|
opts = [actor: actor, groups_found: []]
|
|
|
|
assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts)
|
|
assert result.inserted == 1
|
|
|
|
assert group_names_for("g-new@example.com", actor) == ["Frische Gruppe"]
|
|
assert Enum.any?(Mv.Membership.list_groups!(actor: actor), &(&1.name == "Frische Gruppe"))
|
|
end
|
|
|
|
test "handles multiple comma-separated groups", %{actor: actor} do
|
|
chunk = [
|
|
{2, %{member: %{email: "g-multi@example.com"}, custom: %{}, groups: "Orchester, Chor"}}
|
|
]
|
|
|
|
opts = [actor: actor, groups_found: []]
|
|
|
|
assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts)
|
|
assert result.inserted == 1
|
|
|
|
assert group_names_for("g-multi@example.com", actor) == ["Chor", "Orchester"]
|
|
end
|
|
|
|
test "does not re-read the group table once per row for a repeated novel name",
|
|
%{actor: actor} do
|
|
rows =
|
|
for i <- 1..10 do
|
|
{i + 1,
|
|
%{member: %{email: "g-nplus1-#{i}@example.com"}, custom: %{}, groups: "Shared Group"}}
|
|
end
|
|
|
|
group_read_count = Agent.start_link(fn -> 0 end) |> elem(1)
|
|
test_pid = self()
|
|
|
|
# process_chunk runs synchronously in this test process, so the telemetry
|
|
# handler (invoked in the query-executing process) sees self() == test_pid.
|
|
# Filtering on the pid keeps concurrent tests' group queries out of the count.
|
|
handler = fn _event, _measurements, metadata, _config ->
|
|
if self() == test_pid and metadata[:source] == "groups" and
|
|
is_binary(metadata[:query]) and String.starts_with?(metadata.query, "SELECT") do
|
|
Agent.update(group_read_count, &(&1 + 1))
|
|
end
|
|
end
|
|
|
|
handler_id = "test-group-read-counter-#{System.unique_integer([:positive])}"
|
|
:telemetry.attach(handler_id, [:mv, :repo, :query], handler, nil)
|
|
|
|
assert {:ok, %{inserted: 10}} =
|
|
MemberCSV.process_chunk(rows, %{email: 0}, %{}, actor: actor, groups_found: [])
|
|
|
|
reads = Agent.get(group_read_count, & &1)
|
|
:telemetry.detach(handler_id)
|
|
|
|
# The novel group is created on the first row and reused in memory for the
|
|
# remaining nine. Without accumulation each row triggers a fresh full-table
|
|
# read, scaling linearly with the row count.
|
|
assert reads <= 3,
|
|
"Expected the group table read at most a few times, got #{reads} reads for 10 rows (N+1)."
|
|
end
|
|
|
|
test "returns the grown group snapshot so later chunks skip the table read",
|
|
%{actor: actor} do
|
|
chunk1 = [
|
|
{2, %{member: %{email: "g-xchunk-1@example.com"}, custom: %{}, groups: "Shared X"}}
|
|
]
|
|
|
|
chunk2 = [
|
|
{3, %{member: %{email: "g-xchunk-2@example.com"}, custom: %{}, groups: "Shared X"}}
|
|
]
|
|
|
|
assert {:ok, result1} =
|
|
MemberCSV.process_chunk(chunk1, %{email: 0}, %{}, actor: actor, groups_found: [])
|
|
|
|
# The chunk result must expose the accumulated snapshot, including the group
|
|
# auto-created while processing this chunk, so the LiveView can thread it
|
|
# into the next chunk's opts.
|
|
assert is_list(result1.groups_found)
|
|
assert Enum.any?(result1.groups_found, &(&1.name == "Shared X"))
|
|
|
|
group_read_count = Agent.start_link(fn -> 0 end) |> elem(1)
|
|
test_pid = self()
|
|
|
|
handler = fn _event, _measurements, metadata, _config ->
|
|
if self() == test_pid and metadata[:source] == "groups" and
|
|
is_binary(metadata[:query]) and String.starts_with?(metadata.query, "SELECT") do
|
|
Agent.update(group_read_count, &(&1 + 1))
|
|
end
|
|
end
|
|
|
|
handler_id = "test-xchunk-group-read-#{System.unique_integer([:positive])}"
|
|
:telemetry.attach(handler_id, [:mv, :repo, :query], handler, nil)
|
|
|
|
assert {:ok, %{inserted: 1}} =
|
|
MemberCSV.process_chunk(chunk2, %{email: 0}, %{},
|
|
actor: actor,
|
|
groups_found: result1.groups_found
|
|
)
|
|
|
|
reads = Agent.get(group_read_count, & &1)
|
|
:telemetry.detach(handler_id)
|
|
|
|
# The second chunk receives the snapshot grown by the first, so the shared
|
|
# group resolves from memory without any full-table read.
|
|
assert reads == 0,
|
|
"Expected no group table read in the second chunk, got #{reads} (snapshot not threaded across chunks)."
|
|
end
|
|
|
|
test "empty groups cell leaves the member without group assignment", %{actor: actor} do
|
|
chunk = [{2, %{member: %{email: "g-empty@example.com"}, custom: %{}, groups: " "}}]
|
|
opts = [actor: actor, groups_found: []]
|
|
|
|
assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts)
|
|
assert result.inserted == 1
|
|
assert result.errors == []
|
|
|
|
assert group_names_for("g-empty@example.com", actor) == []
|
|
end
|
|
|
|
property "re-importing the same groups does not create duplicates", %{actor: actor} do
|
|
check all(
|
|
name <- StreamData.string(:alphanumeric, min_length: 1, max_length: 15),
|
|
max_runs: 15
|
|
) do
|
|
group_name = "dup-" <> name
|
|
email1 = "dup-#{System.unique_integer([:positive])}@example.com"
|
|
email2 = "dup-#{System.unique_integer([:positive])}@example.com"
|
|
opts = [actor: actor, groups_found: []]
|
|
|
|
chunk1 = [{2, %{member: %{email: email1}, custom: %{}, groups: group_name}}]
|
|
chunk2 = [{2, %{member: %{email: email2}, custom: %{}, groups: group_name}}]
|
|
|
|
assert {:ok, %{inserted: 1}} = MemberCSV.process_chunk(chunk1, %{email: 0}, %{}, opts)
|
|
assert {:ok, %{inserted: 1}} = MemberCSV.process_chunk(chunk2, %{email: 0}, %{}, opts)
|
|
|
|
matching =
|
|
Mv.Membership.list_groups!(actor: actor)
|
|
|> Enum.filter(&(String.downcase(&1.name) == String.downcase(group_name)))
|
|
|
|
assert length(matching) == 1
|
|
end
|
|
end
|
|
end
|
|
end
|