mitgliederverwaltung/test/mv/membership/import/column_resolver_test.exs

227 lines
7.2 KiB
Elixir

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