439 lines
14 KiB
Elixir
439 lines
14 KiB
Elixir
defmodule Mv.Membership.Import.HeaderMapperTest do
|
||
use ExUnit.Case, async: true
|
||
use ExUnitProperties
|
||
|
||
alias Mv.Membership.Import.HeaderMapper
|
||
|
||
describe "normalize_header/1" do
|
||
test "trims whitespace" do
|
||
assert HeaderMapper.normalize_header(" email ") == "email"
|
||
end
|
||
|
||
test "converts to lowercase" do
|
||
assert HeaderMapper.normalize_header("EMAIL") == "email"
|
||
assert HeaderMapper.normalize_header("E-Mail") == "e-mail"
|
||
end
|
||
|
||
test "normalizes Unicode characters" do
|
||
# ß -> ss
|
||
assert HeaderMapper.normalize_header("Straße") == "strasse"
|
||
# Umlaute transliteration (ä -> ae, ö -> oe, ü -> ue)
|
||
assert HeaderMapper.normalize_header("Müller") == "mueller"
|
||
assert HeaderMapper.normalize_header("Köln") == "koeln"
|
||
assert HeaderMapper.normalize_header("Grün") == "gruen"
|
||
end
|
||
|
||
test "compresses and removes whitespace" do
|
||
# Whitespace is removed entirely to ensure "first name" == "firstname"
|
||
assert HeaderMapper.normalize_header("first name") == "firstname"
|
||
assert HeaderMapper.normalize_header("email address") == "emailaddress"
|
||
end
|
||
|
||
test "unifies hyphen variants" do
|
||
# Different Unicode hyphen characters should become standard hyphen
|
||
# en dash
|
||
assert HeaderMapper.normalize_header("E–Mail") == "e-mail"
|
||
# minus sign
|
||
assert HeaderMapper.normalize_header("E−Mail") == "e-mail"
|
||
# standard hyphen
|
||
assert HeaderMapper.normalize_header("E-Mail") == "e-mail"
|
||
end
|
||
|
||
test "removes or unifies punctuation" do
|
||
# Parentheses, slashes, etc. are removed (whitespace is also removed)
|
||
assert HeaderMapper.normalize_header("E-Mail (privat)") == "e-mailprivat"
|
||
assert HeaderMapper.normalize_header("Telefon / Mobil") == "telefonmobil"
|
||
end
|
||
|
||
test "handles empty strings" do
|
||
assert HeaderMapper.normalize_header("") == ""
|
||
assert HeaderMapper.normalize_header(" ") == ""
|
||
end
|
||
end
|
||
|
||
describe "build_maps/2" do
|
||
test "maps English email variant correctly" do
|
||
headers = ["Email"]
|
||
|
||
assert {:ok, %{member: member_map, custom: custom_map, unknown: unknown}} =
|
||
HeaderMapper.build_maps(headers, [])
|
||
|
||
assert member_map[:email] == 0
|
||
assert custom_map == %{}
|
||
assert unknown == []
|
||
end
|
||
|
||
test "maps German email variant correctly" do
|
||
headers = ["E-Mail"]
|
||
|
||
assert {:ok, %{member: member_map, custom: custom_map, unknown: unknown}} =
|
||
HeaderMapper.build_maps(headers, [])
|
||
|
||
assert member_map[:email] == 0
|
||
assert custom_map == %{}
|
||
assert unknown == []
|
||
end
|
||
|
||
test "maps multiple member fields" do
|
||
headers = ["Email", "First Name", "Last Name"]
|
||
|
||
assert {:ok, %{member: member_map, custom: custom_map, unknown: unknown}} =
|
||
HeaderMapper.build_maps(headers, [])
|
||
|
||
assert member_map[:email] == 0
|
||
assert member_map[:first_name] == 1
|
||
assert member_map[:last_name] == 2
|
||
assert custom_map == %{}
|
||
assert unknown == []
|
||
end
|
||
|
||
test "handles Unicode and whitespace in headers" do
|
||
headers = [" E-Mail ", "Straße", " Telefon / Mobil "]
|
||
|
||
assert {:ok, %{member: member_map, custom: custom_map, unknown: unknown}} =
|
||
HeaderMapper.build_maps(headers, [])
|
||
|
||
assert member_map[:email] == 0
|
||
assert member_map[:street] == 1
|
||
# "Telefon / Mobil" is not a known member field, so it should be unknown
|
||
assert length(unknown) == 1
|
||
assert custom_map == %{}
|
||
end
|
||
|
||
test "returns error when duplicate headers normalize to same field" do
|
||
headers = ["Email", "E-Mail"]
|
||
|
||
assert {:error, reason} = HeaderMapper.build_maps(headers, [])
|
||
assert reason =~ "duplicate"
|
||
assert reason =~ "email"
|
||
end
|
||
|
||
test "returns error when required field email is missing" do
|
||
headers = ["First Name", "Last Name"]
|
||
|
||
assert {:error, reason} = HeaderMapper.build_maps(headers, [])
|
||
assert reason =~ "Missing required header"
|
||
assert reason =~ "email"
|
||
assert reason =~ "accepted"
|
||
end
|
||
|
||
test "collects unknown columns" do
|
||
headers = ["Email", "FooBar", "UnknownColumn"]
|
||
|
||
assert {:ok, %{member: member_map, custom: custom_map, unknown: unknown}} =
|
||
HeaderMapper.build_maps(headers, [])
|
||
|
||
assert member_map[:email] == 0
|
||
assert length(unknown) == 2
|
||
assert "FooBar" in unknown or "foobar" in unknown
|
||
assert "UnknownColumn" in unknown or "unknowncolumn" in unknown
|
||
assert custom_map == %{}
|
||
end
|
||
|
||
test "ignores empty headers after normalization" do
|
||
headers = ["Email", " ", ""]
|
||
|
||
assert {:ok, %{member: member_map, custom: custom_map, unknown: unknown}} =
|
||
HeaderMapper.build_maps(headers, [])
|
||
|
||
assert member_map[:email] == 0
|
||
assert custom_map == %{}
|
||
assert unknown == []
|
||
end
|
||
|
||
test "maps custom field columns correctly" do
|
||
headers = ["Email", "Lieblingsfarbe"]
|
||
custom_fields = [%{id: "cf1", name: "Lieblingsfarbe"}]
|
||
|
||
assert {:ok, %{member: member_map, custom: custom_map, unknown: unknown}} =
|
||
HeaderMapper.build_maps(headers, custom_fields)
|
||
|
||
assert member_map[:email] == 0
|
||
assert custom_map["cf1"] == 1
|
||
assert unknown == []
|
||
end
|
||
|
||
test "custom field collision: member field wins" do
|
||
headers = ["Email"]
|
||
# Custom field with name "Email" should not override member field
|
||
custom_fields = [%{id: "cf1", name: "Email"}]
|
||
|
||
assert {:ok, %{member: member_map, custom: custom_map, unknown: unknown}} =
|
||
HeaderMapper.build_maps(headers, custom_fields)
|
||
|
||
assert member_map[:email] == 0
|
||
# Custom field should not be in custom_map because member field has priority
|
||
assert custom_map == %{}
|
||
assert unknown == []
|
||
end
|
||
|
||
test "handles custom field with Unicode normalization" do
|
||
headers = ["Email", "Straße"]
|
||
custom_fields = [%{id: "cf1", name: "Straße"}]
|
||
|
||
assert {:ok, %{member: member_map, custom: custom_map, unknown: unknown}} =
|
||
HeaderMapper.build_maps(headers, custom_fields)
|
||
|
||
assert member_map[:email] == 0
|
||
# "Straße" is a member field (street), so it should be in member_map, not custom_map
|
||
assert member_map[:street] == 1
|
||
assert custom_map == %{}
|
||
assert unknown == []
|
||
end
|
||
|
||
test "handles unknown custom field columns" do
|
||
headers = ["Email", "UnknownCustomField"]
|
||
custom_fields = [%{id: "cf1", name: "KnownField"}]
|
||
|
||
assert {:ok, %{member: member_map, custom: custom_map, unknown: unknown}} =
|
||
HeaderMapper.build_maps(headers, custom_fields)
|
||
|
||
assert member_map[:email] == 0
|
||
assert custom_map == %{}
|
||
# UnknownCustomField should be in unknown list
|
||
assert length(unknown) == 1
|
||
end
|
||
|
||
test "handles duplicate custom field names after normalization" do
|
||
headers = ["Email", "CustomField", "Custom Field"]
|
||
custom_fields = [%{id: "cf1", name: "CustomField"}]
|
||
|
||
# Both "CustomField" and "Custom Field" normalize to the same, so this should error
|
||
assert {:error, reason} = HeaderMapper.build_maps(headers, custom_fields)
|
||
assert reason =~ "duplicate"
|
||
end
|
||
|
||
test "maps all supported member fields" do
|
||
headers = [
|
||
"Email",
|
||
"First Name",
|
||
"Last Name",
|
||
"Join Date",
|
||
"Exit Date",
|
||
"Notes",
|
||
"Country",
|
||
"City",
|
||
"Street",
|
||
"House Number",
|
||
"Postal Code",
|
||
"Membership Fee Start Date"
|
||
]
|
||
|
||
assert {:ok, %{member: member_map, custom: custom_map, unknown: unknown}} =
|
||
HeaderMapper.build_maps(headers, [])
|
||
|
||
assert member_map[:email] == 0
|
||
assert member_map[:first_name] == 1
|
||
assert member_map[:last_name] == 2
|
||
assert member_map[:join_date] == 3
|
||
assert member_map[:exit_date] == 4
|
||
assert member_map[:notes] == 5
|
||
assert member_map[:country] == 6
|
||
assert member_map[:city] == 7
|
||
assert member_map[:street] == 8
|
||
assert member_map[:house_number] == 9
|
||
assert member_map[:postal_code] == 10
|
||
assert member_map[:membership_fee_start_date] == 11
|
||
assert custom_map == %{}
|
||
assert unknown == []
|
||
end
|
||
|
||
test "maps German member field variants" do
|
||
headers = [
|
||
"E-Mail",
|
||
"Vorname",
|
||
"Nachname",
|
||
"Beitrittsdatum",
|
||
"Austrittsdatum",
|
||
"Notizen",
|
||
"Land",
|
||
"Stadt",
|
||
"Straße",
|
||
"Hausnummer",
|
||
"PLZ",
|
||
"Beitragsbeginn"
|
||
]
|
||
|
||
assert {:ok, %{member: member_map, custom: custom_map, unknown: unknown}} =
|
||
HeaderMapper.build_maps(headers, [])
|
||
|
||
assert member_map[:email] == 0
|
||
assert member_map[:first_name] == 1
|
||
assert member_map[:last_name] == 2
|
||
assert member_map[:join_date] == 3
|
||
assert member_map[:exit_date] == 4
|
||
assert member_map[:notes] == 5
|
||
assert member_map[:country] == 6
|
||
assert member_map[:city] == 7
|
||
assert member_map[:street] == 8
|
||
assert member_map[:house_number] == 9
|
||
assert member_map[:postal_code] == 10
|
||
assert member_map[:membership_fee_start_date] == 11
|
||
assert custom_map == %{}
|
||
assert unknown == []
|
||
end
|
||
end
|
||
|
||
describe "build_maps/2 fee-status ignore list" do
|
||
test "places fee-status variants in ignored, not member or custom map" do
|
||
headers = ["email", "Bezahlstatus"]
|
||
|
||
assert {:ok, result} = HeaderMapper.build_maps(headers, [])
|
||
|
||
assert result.member[:email] == 0
|
||
assert result.custom == %{}
|
||
assert result.ignored == [1]
|
||
refute Map.has_key?(result.member, :bezahlstatus)
|
||
end
|
||
|
||
test "ignores membership_fee_status snake-case variant" do
|
||
headers = ["email", "membership_fee_status"]
|
||
|
||
assert {:ok, result} = HeaderMapper.build_maps(headers, [])
|
||
|
||
assert result.ignored == [1]
|
||
assert result.custom == %{}
|
||
end
|
||
|
||
test "ignores German Mitgliedsbeitragsstatus variant" do
|
||
headers = ["email", "Mitgliedsbeitragsstatus"]
|
||
|
||
assert {:ok, result} = HeaderMapper.build_maps(headers, [])
|
||
|
||
assert result.ignored == [1]
|
||
end
|
||
|
||
test "fee-status takes priority over a same-named custom field" do
|
||
headers = ["email", "Bezahlstatus"]
|
||
custom_fields = [%{id: "cf1", name: "Bezahlstatus"}]
|
||
|
||
assert {:ok, result} = HeaderMapper.build_maps(headers, custom_fields)
|
||
|
||
assert result.ignored == [1]
|
||
assert result.custom == %{}
|
||
end
|
||
|
||
test "result carries groups_column_index and fee_type_column_index keys" do
|
||
assert {:ok, result} = HeaderMapper.build_maps(["email"], [])
|
||
|
||
assert Map.has_key?(result, :groups_column_index)
|
||
assert Map.has_key?(result, :fee_type_column_index)
|
||
end
|
||
end
|
||
|
||
describe "build_maps/2 groups column detection" do
|
||
test "detects German Gruppen variant and excludes it from member/custom maps" do
|
||
headers = ["email", "Gruppen"]
|
||
|
||
assert {:ok, result} = HeaderMapper.build_maps(headers, [])
|
||
|
||
assert result.groups_column_index == 1
|
||
assert result.custom == %{}
|
||
assert result.unknown == []
|
||
refute Map.has_key?(result.member, :gruppen)
|
||
end
|
||
|
||
test "detects English Groups variant" do
|
||
headers = ["email", "Groups"]
|
||
|
||
assert {:ok, result} = HeaderMapper.build_maps(headers, [])
|
||
|
||
assert result.groups_column_index == 1
|
||
end
|
||
|
||
test "detects singular Gruppe and lowercase groups variants" do
|
||
assert {:ok, %{groups_column_index: 1}} = HeaderMapper.build_maps(["email", "Gruppe"], [])
|
||
assert {:ok, %{groups_column_index: 1}} = HeaderMapper.build_maps(["email", "groups"], [])
|
||
end
|
||
|
||
test "groups column takes priority over a same-named custom field" do
|
||
headers = ["email", "Gruppen"]
|
||
custom_fields = [%{id: "cf1", name: "Gruppen"}]
|
||
|
||
assert {:ok, result} = HeaderMapper.build_maps(headers, custom_fields)
|
||
|
||
assert result.groups_column_index == 1
|
||
assert result.custom == %{}
|
||
end
|
||
|
||
test "groups_column_index is nil when no groups column present" do
|
||
assert {:ok, %{groups_column_index: nil}} = HeaderMapper.build_maps(["email"], [])
|
||
end
|
||
end
|
||
|
||
describe "build_maps/2 fee-type column detection" do
|
||
test "detects German Beitragsart variant and excludes it from member/custom maps" do
|
||
headers = ["email", "Beitragsart"]
|
||
|
||
assert {:ok, result} = HeaderMapper.build_maps(headers, [])
|
||
|
||
assert result.fee_type_column_index == 1
|
||
assert result.custom == %{}
|
||
assert result.unknown == []
|
||
end
|
||
|
||
test "detects English fee type variants" do
|
||
assert {:ok, %{fee_type_column_index: 1}} =
|
||
HeaderMapper.build_maps(["email", "Fee Type"], [])
|
||
|
||
assert {:ok, %{fee_type_column_index: 1}} =
|
||
HeaderMapper.build_maps(["email", "fee type"], [])
|
||
|
||
assert {:ok, %{fee_type_column_index: 1}} =
|
||
HeaderMapper.build_maps(["email", "fee_type"], [])
|
||
|
||
assert {:ok, %{fee_type_column_index: 1}} =
|
||
HeaderMapper.build_maps(["email", "membership_fee_type"], [])
|
||
end
|
||
|
||
test "fee-type column takes priority over a same-named custom field" do
|
||
headers = ["email", "Beitragsart"]
|
||
custom_fields = [%{id: "cf1", name: "Beitragsart"}]
|
||
|
||
assert {:ok, result} = HeaderMapper.build_maps(headers, custom_fields)
|
||
|
||
assert result.fee_type_column_index == 1
|
||
assert result.custom == %{}
|
||
end
|
||
|
||
test "fee_type_column_index is nil when no fee-type column present" do
|
||
assert {:ok, %{fee_type_column_index: nil}} = HeaderMapper.build_maps(["email"], [])
|
||
end
|
||
|
||
test "detects groups and fee-type columns together" do
|
||
headers = ["email", "Gruppen", "Beitragsart"]
|
||
|
||
assert {:ok, result} = HeaderMapper.build_maps(headers, [])
|
||
|
||
assert result.groups_column_index == 1
|
||
assert result.fee_type_column_index == 2
|
||
assert result.member[:email] == 0
|
||
assert result.custom == %{}
|
||
assert result.unknown == []
|
||
end
|
||
end
|
||
|
||
describe "build_maps/2 fee-status ignore property" do
|
||
property "every fee-status variant is ignored, never member or custom" do
|
||
check all(
|
||
variant <-
|
||
StreamData.member_of([
|
||
"Membership Fee Status",
|
||
"membership_fee_status",
|
||
"Mitgliedsbeitragsstatus",
|
||
"Bezahlstatus",
|
||
" Bezahlstatus ",
|
||
"BEZAHLSTATUS"
|
||
])
|
||
) do
|
||
custom_fields = [%{id: "cf1", name: variant}]
|
||
|
||
assert {:ok, result} = HeaderMapper.build_maps(["email", variant], custom_fields)
|
||
|
||
assert result.ignored == [1]
|
||
assert result.custom == %{}
|
||
refute Map.has_key?(result.member, :bezahlstatus)
|
||
end
|
||
end
|
||
end
|
||
end
|