feat(import): assign groups and fee types to imported members, creating missing groups
This commit is contained in:
parent
a4a34cab3a
commit
00e1624ee4
6 changed files with 517 additions and 51 deletions
|
|
@ -1,5 +1,6 @@
|
|||
defmodule Mv.Membership.Import.MemberCSVTest do
|
||||
use Mv.DataCase, async: true
|
||||
use ExUnitProperties
|
||||
|
||||
alias Mv.Membership.Import.MemberCSV
|
||||
|
||||
|
|
@ -899,4 +900,255 @@ defmodule Mv.Membership.Import.MemberCSVTest do
|
|||
assert import_state.chunks != []
|
||||
end
|
||||
end
|
||||
|
||||
describe "prepare/2 column resolution integration" do
|
||||
setup do
|
||||
%{actor: Mv.Helpers.SystemActor.get_system_actor()}
|
||||
end
|
||||
|
||||
test "exposes resolver output keys in import_state", %{actor: actor} do
|
||||
csv_content = "email\njohn@example.com"
|
||||
|
||||
assert {:ok, import_state} = MemberCSV.prepare(csv_content, actor: actor)
|
||||
|
||||
assert Map.has_key?(import_state, :ignored)
|
||||
assert Map.has_key?(import_state, :groups_to_create)
|
||||
assert Map.has_key?(import_state, :fee_type_map)
|
||||
assert Map.has_key?(import_state, :fee_type_warnings)
|
||||
assert Map.has_key?(import_state, :has_empty_fee_type_cells?)
|
||||
assert Map.has_key?(import_state, :preview_rows)
|
||||
end
|
||||
|
||||
test "fee-status column is reported as ignored, not as a custom field", %{actor: actor} do
|
||||
{:ok, _custom_field} =
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{name: "Bezahlstatus", value_type: :string})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
csv_content = "email;Bezahlstatus\njohn@example.com;paid"
|
||||
|
||||
assert {:ok, import_state} = MemberCSV.prepare(csv_content, actor: actor)
|
||||
|
||||
assert import_state.ignored == ["Bezahlstatus"]
|
||||
assert import_state.custom_field_map == %{}
|
||||
end
|
||||
|
||||
test "preview rows are limited to 3", %{actor: actor} do
|
||||
csv_content = "email\na@example.com\nb@example.com\nc@example.com\nd@example.com"
|
||||
|
||||
assert {:ok, import_state} = MemberCSV.prepare(csv_content, actor: actor)
|
||||
|
||||
assert length(import_state.preview_rows) == 3
|
||||
end
|
||||
end
|
||||
|
||||
describe "process_chunk/4 fee-type assignment" do
|
||||
setup do
|
||||
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, fee_type} =
|
||||
Mv.MembershipFees.create_membership_fee_type(
|
||||
%{name: "Premium", amount: Decimal.new("25.00"), interval: :yearly},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
%{actor: actor, fee_type: fee_type}
|
||||
end
|
||||
|
||||
test "sets membership_fee_type_id when fee-type cell matches a known type", %{
|
||||
actor: actor,
|
||||
fee_type: fee_type
|
||||
} do
|
||||
chunk = [
|
||||
{2, %{member: %{email: "fee-known@example.com"}, custom: %{}, fee_type: "Premium"}}
|
||||
]
|
||||
|
||||
opts = [
|
||||
actor: actor,
|
||||
fee_type_map: %{"premium" => fee_type.id}
|
||||
]
|
||||
|
||||
assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts)
|
||||
assert result.inserted == 1
|
||||
|
||||
member =
|
||||
Mv.Membership.list_members!(actor: actor)
|
||||
|> Enum.find(&(&1.email == "fee-known@example.com"))
|
||||
|
||||
assert member.membership_fee_type_id == fee_type.id
|
||||
end
|
||||
|
||||
test "adds a warning when the fee-type name is unknown", %{actor: actor} do
|
||||
chunk = [
|
||||
{2, %{member: %{email: "fee-unknown@example.com"}, custom: %{}, fee_type: "Ghost Type"}}
|
||||
]
|
||||
|
||||
opts = [actor: actor, fee_type_map: %{}]
|
||||
|
||||
assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts)
|
||||
assert result.inserted == 1
|
||||
assert Enum.any?(result.warnings, &(&1 =~ "Ghost Type"))
|
||||
end
|
||||
|
||||
test "uses the default fee type when the fee-type cell is empty", %{
|
||||
actor: actor,
|
||||
fee_type: fee_type
|
||||
} do
|
||||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
|
||||
{:ok, _settings} =
|
||||
Mv.Membership.update_settings(
|
||||
settings,
|
||||
%{default_membership_fee_type_id: fee_type.id},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
chunk = [{2, %{member: %{email: "fee-empty@example.com"}, custom: %{}, fee_type: ""}}]
|
||||
|
||||
opts = [actor: actor, fee_type_map: %{}]
|
||||
|
||||
assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts)
|
||||
assert result.inserted == 1
|
||||
|
||||
member =
|
||||
Mv.Membership.list_members!(actor: actor)
|
||||
|> Enum.find(&(&1.email == "fee-empty@example.com"))
|
||||
|
||||
# Default fee type assigned via SetDefaultMembershipFeeType.
|
||||
assert member.membership_fee_type_id == fee_type.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "process_chunk/4 group assignment" do
|
||||
setup do
|
||||
%{actor: Mv.Helpers.SystemActor.get_system_actor()}
|
||||
end
|
||||
|
||||
defp group_names_for(email, actor) do
|
||||
member =
|
||||
Mv.Membership.list_members!(actor: actor)
|
||||
|> Enum.find(&(&1.email == email))
|
||||
|
||||
member = Ash.load!(member, :groups, actor: actor)
|
||||
member.groups |> Enum.map(& &1.name) |> Enum.sort()
|
||||
end
|
||||
|
||||
test "assigns member to an existing group", %{actor: actor} do
|
||||
existing = Mv.Fixtures.group_fixture(%{name: "Orchester"})
|
||||
|
||||
chunk = [
|
||||
{2, %{member: %{email: "g-existing@example.com"}, custom: %{}, groups: "Orchester"}}
|
||||
]
|
||||
|
||||
opts = [actor: actor, groups_found: [existing]]
|
||||
|
||||
assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts)
|
||||
assert result.inserted == 1
|
||||
|
||||
assert group_names_for("g-existing@example.com", actor) == ["Orchester"]
|
||||
|
||||
# No new group was created.
|
||||
orchester = Enum.filter(Mv.Membership.list_groups!(actor: actor), &(&1.name == "Orchester"))
|
||||
assert length(orchester) == 1
|
||||
end
|
||||
|
||||
test "auto-creates an unknown group and assigns the member", %{actor: actor} do
|
||||
chunk = [
|
||||
{2, %{member: %{email: "g-new@example.com"}, custom: %{}, groups: "Frische Gruppe"}}
|
||||
]
|
||||
|
||||
opts = [actor: actor, groups_found: []]
|
||||
|
||||
assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts)
|
||||
assert result.inserted == 1
|
||||
|
||||
assert group_names_for("g-new@example.com", actor) == ["Frische Gruppe"]
|
||||
assert Enum.any?(Mv.Membership.list_groups!(actor: actor), &(&1.name == "Frische Gruppe"))
|
||||
end
|
||||
|
||||
test "handles multiple comma-separated groups", %{actor: actor} do
|
||||
chunk = [
|
||||
{2, %{member: %{email: "g-multi@example.com"}, custom: %{}, groups: "Orchester, Chor"}}
|
||||
]
|
||||
|
||||
opts = [actor: actor, groups_found: []]
|
||||
|
||||
assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts)
|
||||
assert result.inserted == 1
|
||||
|
||||
assert group_names_for("g-multi@example.com", actor) == ["Chor", "Orchester"]
|
||||
end
|
||||
|
||||
test "does not re-read the group table once per row for a repeated novel name",
|
||||
%{actor: actor} do
|
||||
rows =
|
||||
for i <- 1..10 do
|
||||
{i + 1,
|
||||
%{member: %{email: "g-nplus1-#{i}@example.com"}, custom: %{}, groups: "Shared Group"}}
|
||||
end
|
||||
|
||||
group_read_count = Agent.start_link(fn -> 0 end) |> elem(1)
|
||||
test_pid = self()
|
||||
|
||||
# process_chunk runs synchronously in this test process, so the telemetry
|
||||
# handler (invoked in the query-executing process) sees self() == test_pid.
|
||||
# Filtering on the pid keeps concurrent tests' group queries out of the count.
|
||||
handler = fn _event, _measurements, metadata, _config ->
|
||||
if self() == test_pid and metadata[:source] == "groups" and
|
||||
is_binary(metadata[:query]) and String.starts_with?(metadata.query, "SELECT") do
|
||||
Agent.update(group_read_count, &(&1 + 1))
|
||||
end
|
||||
end
|
||||
|
||||
handler_id = "test-group-read-counter-#{System.unique_integer([:positive])}"
|
||||
:telemetry.attach(handler_id, [:mv, :repo, :query], handler, nil)
|
||||
|
||||
assert {:ok, %{inserted: 10}} =
|
||||
MemberCSV.process_chunk(rows, %{email: 0}, %{}, actor: actor, groups_found: [])
|
||||
|
||||
reads = Agent.get(group_read_count, & &1)
|
||||
:telemetry.detach(handler_id)
|
||||
|
||||
# The novel group is created on the first row and reused in memory for the
|
||||
# remaining nine. Without accumulation each row triggers a fresh full-table
|
||||
# read, scaling linearly with the row count.
|
||||
assert reads <= 3,
|
||||
"Expected the group table read at most a few times, got #{reads} reads for 10 rows (N+1)."
|
||||
end
|
||||
|
||||
test "empty groups cell leaves the member without group assignment", %{actor: actor} do
|
||||
chunk = [{2, %{member: %{email: "g-empty@example.com"}, custom: %{}, groups: " "}}]
|
||||
opts = [actor: actor, groups_found: []]
|
||||
|
||||
assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts)
|
||||
assert result.inserted == 1
|
||||
assert result.errors == []
|
||||
|
||||
assert group_names_for("g-empty@example.com", actor) == []
|
||||
end
|
||||
|
||||
property "re-importing the same groups does not create duplicates", %{actor: actor} do
|
||||
check all(
|
||||
name <- StreamData.string(:alphanumeric, min_length: 1, max_length: 15),
|
||||
max_runs: 15
|
||||
) do
|
||||
group_name = "dup-" <> name
|
||||
email1 = "dup-#{System.unique_integer([:positive])}@example.com"
|
||||
email2 = "dup-#{System.unique_integer([:positive])}@example.com"
|
||||
opts = [actor: actor, groups_found: []]
|
||||
|
||||
chunk1 = [{2, %{member: %{email: email1}, custom: %{}, groups: group_name}}]
|
||||
chunk2 = [{2, %{member: %{email: email2}, custom: %{}, groups: group_name}}]
|
||||
|
||||
assert {:ok, %{inserted: 1}} = MemberCSV.process_chunk(chunk1, %{email: 0}, %{}, opts)
|
||||
assert {:ok, %{inserted: 1}} = MemberCSV.process_chunk(chunk2, %{email: 0}, %{}, opts)
|
||||
|
||||
matching =
|
||||
Mv.Membership.list_groups!(actor: actor)
|
||||
|> Enum.filter(&(String.downcase(&1.name) == String.downcase(group_name)))
|
||||
|
||||
assert length(matching) == 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue