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