feat(import): resolve import group and fee-type names against existing records

This commit is contained in:
Moritz 2026-06-03 02:10:33 +02:00
parent 95c7bf7a15
commit a4a34cab3a
3 changed files with 557 additions and 0 deletions

View file

@ -0,0 +1,72 @@
defmodule Mv.Membership.Import.ColumnResolverQueryTest do
# async: false — attaches a global telemetry handler to inspect emitted SQL.
use Mv.DataCase, async: false
alias Mv.Membership.Import.ColumnResolver
setup do
%{actor: Mv.Helpers.SystemActor.get_system_actor()}
end
describe "create_or_find_group/3 group lookup is name-filtered (no full-table scan)" do
test "resolving a new name absent from the snapshot queries by name, not the whole table",
%{actor: actor} do
# Populate the table so a full-table read would be costly and observable.
for n <- 1..20, do: Mv.Fixtures.group_fixture(%{name: "Existing #{n}"})
queries =
capture_group_select_queries(fn ->
# The name is absent from the (empty) snapshot, forcing a DB lookup
# before the create attempt. That lookup must filter by name.
assert {:ok, group} = ColumnResolver.create_or_find_group("New One", [], actor)
assert group.name == "New One"
end)
# No SELECT against the groups table issued during resolution may be an
# unfiltered full-table scan. The pre-create existence check must filter by
# name (carry a WHERE predicate).
refute Enum.any?(queries, &unfiltered_groups_select?/1),
"expected no unfiltered groups table scan, got:\n#{Enum.join(queries, "\n")}"
end
end
defp capture_group_select_queries(fun) do
test_pid = self()
handler_id = "test-group-query-#{System.unique_integer([:positive])}"
:telemetry.attach(
handler_id,
[:mv, :repo, :query],
fn _event, _measurements, metadata, _config ->
sql = metadata[:query] || ""
if String.contains?(sql, "SELECT") and String.contains?(sql, "\"groups\"") do
send(test_pid, {:group_query, sql})
end
end,
nil
)
try do
fun.()
after
:telemetry.detach(handler_id)
end
collect_group_queries([])
end
defp collect_group_queries(acc) do
receive do
{:group_query, sql} -> collect_group_queries([sql | acc])
after
0 -> Enum.reverse(acc)
end
end
# An unfiltered groups SELECT reads the whole table: it selects FROM "groups"
# with no WHERE clause at all. A name-filtered lookup carries a WHERE predicate.
defp unfiltered_groups_select?(sql) do
String.contains?(sql, "FROM \"groups\"") and not String.contains?(sql, "WHERE")
end
end

View file

@ -0,0 +1,227 @@
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