227 lines
7.2 KiB
Elixir
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
|