defmodule Mv.Membership.Import.ColumnResolverTest do use Mv.DataCase, async: true use ExUnitProperties alias Mv.Membership.Import.ColumnResolver setup do %{actor: Mv.Helpers.SystemActor.get_system_actor()} end defp fee_type_fixture(name, actor) do {:ok, fee_type} = Mv.MembershipFees.create_membership_fee_type( %{name: name, amount: Decimal.new("10.00"), interval: :yearly}, actor: actor ) fee_type end defp header_maps(overrides) do Map.merge( %{ member: %{email: 0}, custom: %{}, unknown: [], ignored: [], groups_column_index: nil, fee_type_column_index: nil }, overrides ) end describe "resolve/3 group classification" do test "splits group names into found (existing) and to_create (missing)", %{actor: actor} do existing = Mv.Fixtures.group_fixture(%{name: "Orchester"}) maps = header_maps(%{member: %{email: 0}, groups_column_index: 1}) rows = [ {2, ["a@example.com", "Orchester"]}, {3, ["b@example.com", "Neues Ensemble"]} ] result = ColumnResolver.resolve(maps, rows, actor) assert Enum.any?(result.groups_found, &(&1.name == "Orchester" and &1.id == existing.id)) assert "Neues Ensemble" in result.groups_to_create refute "Orchester" in result.groups_to_create end test "groups_found and groups_to_create are empty when no groups column", %{actor: actor} do maps = header_maps(%{}) rows = [{2, ["a@example.com"]}] result = ColumnResolver.resolve(maps, rows, actor) assert result.groups_found == [] assert result.groups_to_create == [] end end describe "resolve/3 preview rows" do test "returns up to 3 preview rows", %{actor: actor} do maps = header_maps(%{}) rows = [ {2, ["a@example.com"]}, {3, ["b@example.com"]}, {4, ["c@example.com"]}, {5, ["d@example.com"]} ] result = ColumnResolver.resolve(maps, rows, actor) assert length(result.preview_rows) == 3 assert result.preview_rows == [["a@example.com"], ["b@example.com"], ["c@example.com"]] end test "returns fewer preview rows when file has fewer data rows", %{actor: actor} do maps = header_maps(%{}) rows = [{2, ["a@example.com"]}] result = ColumnResolver.resolve(maps, rows, actor) assert result.preview_rows == [["a@example.com"]] end end describe "resolve/3 fee-type resolution" do test "maps known fee-type names to their id by normalized name", %{actor: actor} do standard = fee_type_fixture("Standard", actor) maps = header_maps(%{fee_type_column_index: 1}) rows = [{2, ["a@example.com", "Standard"]}] result = ColumnResolver.resolve(maps, rows, actor) assert result.fee_type_map["standard"] == standard.id assert result.fee_type_warnings == [] end test "records a warning for an unknown fee-type name", %{actor: actor} do maps = header_maps(%{fee_type_column_index: 1}) rows = [{2, ["a@example.com", "Nonexistent Type"]}] result = ColumnResolver.resolve(maps, rows, actor) assert "Nonexistent Type" in result.fee_type_warnings end test "sets has_empty_fee_type_cells? when a fee-type cell is blank", %{actor: actor} do fee_type_fixture("Standard", actor) maps = header_maps(%{fee_type_column_index: 1}) rows = [ {2, ["a@example.com", "Standard"]}, {3, ["b@example.com", " "]} ] result = ColumnResolver.resolve(maps, rows, actor) assert result.has_empty_fee_type_cells? == true end test "has_empty_fee_type_cells? is false when all cells filled", %{actor: actor} do fee_type_fixture("Standard", actor) maps = header_maps(%{fee_type_column_index: 1}) rows = [{2, ["a@example.com", "Standard"]}] result = ColumnResolver.resolve(maps, rows, actor) assert result.has_empty_fee_type_cells? == false end test "fee-type resolution defaults are empty when no fee-type column", %{actor: actor} do maps = header_maps(%{}) rows = [{2, ["a@example.com"]}] result = ColumnResolver.resolve(maps, rows, actor) assert result.fee_type_map == %{} assert result.fee_type_warnings == [] assert result.has_empty_fee_type_cells? == false end end describe "create_or_find_group/3" do test "creates a new group when none exists", %{actor: actor} do assert {:ok, group} = ColumnResolver.create_or_find_group("Brand New Group", [], actor) assert group.name == "Brand New Group" end test "returns the existing group from the pre-fetched list without creating", %{actor: actor} do existing = Mv.Fixtures.group_fixture(%{name: "Existing Group"}) before_count = length(Mv.Membership.list_groups!(actor: actor)) assert {:ok, group} = ColumnResolver.create_or_find_group("Existing Group", [existing], actor) assert group.id == existing.id assert length(Mv.Membership.list_groups!(actor: actor)) == before_count end test "resolves to a group created concurrently after the snapshot was taken", %{actor: actor} do # Simulates a concurrent import session: the group name is absent from the # caller's pre-fetched snapshot, but the group now exists in the DB. The # resolver must link to the existing group, never error or duplicate it. stale_snapshot = [] _concurrently_created = Mv.Fixtures.group_fixture(%{name: "Concurrent Group"}) before_count = length(Mv.Membership.list_groups!(actor: actor)) assert {:ok, group} = ColumnResolver.create_or_find_group("Concurrent Group", stale_snapshot, actor) assert group.name == "Concurrent Group" assert length(Mv.Membership.list_groups!(actor: actor)) == before_count end property "is idempotent: same names never create duplicate groups", %{actor: actor} do check all( names <- StreamData.list_of( StreamData.string(:alphanumeric, min_length: 1, max_length: 20), min_length: 1, max_length: 5 ), max_runs: 25 ) do names = Enum.map(names, &("grp-" <> &1)) existing = Mv.Membership.list_groups!(actor: actor) first_ids = resolve_all(names, existing, actor) existing_after = Mv.Membership.list_groups!(actor: actor) second_ids = resolve_all(names, existing_after, actor) # Same name always resolves to the same group id across both passes. assert first_ids == second_ids # No duplicate groups exist for any of the names (case-insensitive). all_groups = Mv.Membership.list_groups!(actor: actor) for name <- Enum.uniq_by(names, &String.downcase/1) do matching = Enum.filter(all_groups, fn g -> String.downcase(g.name) == String.downcase(name) end) assert length(matching) == 1 end end end end defp resolve_all(names, existing, actor) do Enum.map(names, fn name -> {:ok, group} = ColumnResolver.create_or_find_group(name, existing, actor) {String.downcase(name), group.id} end) |> Map.new() end end