From 95c25142f016962445b32407d072e39cbfed1adf Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 16 Jul 2026 09:39:12 +0200 Subject: [PATCH] feat(seeds): add an opt-in bulk member seed for load testing --- Justfile | 5 ++ priv/repo/seeds_bulk_members.exs | 129 +++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 priv/repo/seeds_bulk_members.exs diff --git a/Justfile b/Justfile index a16bf8b1..9a16e966 100644 --- a/Justfile +++ b/Justfile @@ -30,6 +30,11 @@ reset-database: seed-database: mix run priv/repo/seeds.exs +# Seed a large batch of members for load / infinite-scroll testing (opt-in; NOT +# part of `seed-database`). Set BULK_MEMBER_COUNT to change the count (default 300). +seed-members-bulk: + mix run priv/repo/seeds_bulk_members.exs + start-database: docker compose up -d diff --git a/priv/repo/seeds_bulk_members.exs b/priv/repo/seeds_bulk_members.exs new file mode 100644 index 00000000..cf508fe0 --- /dev/null +++ b/priv/repo/seeds_bulk_members.exs @@ -0,0 +1,129 @@ +# Optional BULK member seed for load / infinite-scroll testing. +# +# This is NOT part of the default dev seed (`mix run priv/repo/seeds.exs`), so the +# normal dev database stays small. Run it explicitly when you need many members: +# +# just seed-members-bulk +# BULK_MEMBER_COUNT=500 mix run priv/repo/seeds_bulk_members.exs +# +# Requires the bootstrap seeds (admin user + fee types) to have run first. Members +# are upserted by email ("bulk-member-@example.de"), so re-running is idempotent +# and does not duplicate members or their fee cycles. + +alias Mv.Accounts +alias Mv.Membership +alias Mv.MembershipFees.CycleGenerator +alias Mv.MembershipFees.MembershipFeeType + +require Ash.Query + +admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost" + +admin_user_with_role = + case Accounts.User + |> Ash.Query.filter(email == ^admin_email) + |> Ash.read_one(domain: Mv.Accounts, authorize?: false) do + {:ok, user} when not is_nil(user) -> + user |> Ash.load!(:role, authorize?: false) + + _ -> + raise "Bulk seed requires bootstrap: admin user not found (#{admin_email})" + end + +all_fee_types = + MembershipFeeType + |> Ash.Query.sort(name: :asc) + |> Ash.read!(actor: admin_user_with_role, domain: Mv.MembershipFees) + |> Enum.to_list() + +count = + case Integer.parse(System.get_env("BULK_MEMBER_COUNT") || "300") do + {n, _} when n > 0 -> n + _ -> 300 + end + +first_names = + ~w(Anna Bruno Clara David Elena Felix Greta Henrik Ines Jakob Laura Max Nina + Oliver Paula Quirin Rosa Stefan Thea Uwe Vera Wolf Xenia Yannick Zoe Ada + Ben Carl Dora Emil) + +last_names = + ~w(Schmidt Müller Fischer Weber Wagner Becker Schulz Hoffmann Koch Richter Klein + Wolf Neumann Schwarz Zimmermann Braun Krüger Hartmann Lange Werner Krause + Lehmann Köhler Herrmann Walter) + +cities = [ + {"München", "80331"}, + {"Hamburg", "20095"}, + {"Berlin", "10435"}, + {"Köln", "50667"}, + {"Frankfurt", "60313"}, + {"Stuttgart", "70173"}, + {"Düsseldorf", "40212"}, + {"Dortmund", "44137"}, + {"Essen", "45127"}, + {"Dresden", "01069"}, + {"Nürnberg", "90402"}, + {"Bremen", "28195"} +] + +statuses = [:paid, :unpaid, :suspended] + +IO.puts("Seeding #{count} bulk members (set BULK_MEMBER_COUNT to change)…") + +Enum.each(1..count, fn i -> + {city, postal_code} = Enum.at(cities, rem(i, length(cities))) + + attrs = %{ + first_name: Enum.at(first_names, rem(i, length(first_names))), + last_name: Enum.at(last_names, rem(div(i, length(first_names)) + i, length(last_names))), + email: "bulk-member-#{i}@example.de", + join_date: Date.add(~D[2020-01-01], rem(i * 7, 2000)), + city: city, + street: "Teststraße", + house_number: Integer.to_string(rem(i, 200) + 1), + postal_code: postal_code, + country: "Deutschland" + } + + attrs = + case all_fee_types do + [] -> attrs + types -> Map.put(attrs, :membership_fee_type_id, Enum.at(types, rem(i, length(types))).id) + end + + member = + Membership.create_member!(attrs, + upsert?: true, + upsert_identity: :unique_email, + actor: admin_user_with_role + ) + + # Give the member fee cycles + a round-robin status so the fee-status column is + # populated at scale. Only generate when none exist yet (idempotent re-runs). + if not is_nil(member.membership_fee_type_id) do + member = Ash.load!(member, :membership_fee_cycles, actor: admin_user_with_role) + + if Enum.empty?(member.membership_fee_cycles) do + {:ok, cycles, _} = + CycleGenerator.generate_cycles_for_member(member.id, + skip_lock?: true, + actor: admin_user_with_role + ) + + status = Enum.at(statuses, rem(i, length(statuses))) + + Enum.each(cycles, fn cycle -> + if cycle.status != status do + cycle + |> Ash.Changeset.for_update(:update, %{status: status}) + |> Ash.update!(actor: admin_user_with_role, domain: Mv.MembershipFees) + end + end) + end + end + + if rem(i, 50) == 0, do: IO.puts(" … #{i}/#{count}") +end) + +IO.puts("✅ Bulk seed complete: #{count} members.")