feat(import): resolve import group and fee-type names against existing records
This commit is contained in:
parent
95c7bf7a15
commit
a4a34cab3a
3 changed files with 557 additions and 0 deletions
72
test/mv/membership/import/column_resolver_query_test.exs
Normal file
72
test/mv/membership/import/column_resolver_query_test.exs
Normal 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
|
||||
227
test/mv/membership/import/column_resolver_test.exs
Normal file
227
test/mv/membership/import/column_resolver_test.exs
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue