mitgliederverwaltung/test/membership_fees/member_cycle_integration_test.exs
Moritz 82897d5cd3
All checks were successful
continuous-integration/drone/push Build is passing
refactor: improve cycle generation code quality and documentation
- Remove Process.sleep calls from integration tests (tests run synchronously in SQL sandbox)
- Improve error handling: membership_fee_type_not_found now returns changeset error instead of just logging
- Clarify partial_failure documentation: successful_cycles are not persisted on rollback
- Update documentation: joined_at → join_date, left_at → exit_date
- Document PostgreSQL advisory locks per member (not whole table lock)
- Document gap handling: explicitly deleted cycles are not recreated
2025-12-12 17:41:22 +01:00

211 lines
6.2 KiB
Elixir

defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
@moduledoc """
Integration tests for membership fee cycle generation triggered by member actions.
"""
use Mv.DataCase, async: false
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership.Member
require Ash.Query
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
# Helper to set up settings
defp setup_settings(include_joining_cycle) do
{:ok, settings} = Mv.Membership.get_settings()
settings
|> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle})
|> Ash.update!()
end
# Helper to get cycles for a member
defp get_member_cycles(member_id) do
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member_id)
|> Ash.Query.sort(cycle_start: :asc)
|> Ash.read!()
end
describe "member creation triggers cycle generation" do
test "creates cycles when member is created with fee type and join_date" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
member =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-03-15],
membership_fee_type_id: fee_type.id
})
|> Ash.create!()
cycles = get_member_cycles(member.id)
# Should have cycles for 2023 and 2024 (and possibly current year)
assert length(cycles) >= 2
# Verify cycles have correct data
Enum.each(cycles, fn cycle ->
assert cycle.member_id == member.id
assert cycle.membership_fee_type_id == fee_type.id
assert Decimal.equal?(cycle.amount, fee_type.amount)
assert cycle.status == :unpaid
end)
end
test "does not create cycles when member has no fee type" do
setup_settings(true)
member =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-03-15]
# No membership_fee_type_id
})
|> Ash.create!()
cycles = get_member_cycles(member.id)
assert cycles == []
end
test "does not create cycles when member has no join_date" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
member =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
# No join_date
})
|> Ash.create!()
cycles = get_member_cycles(member.id)
assert cycles == []
end
end
describe "member update triggers cycle generation" do
test "generates cycles when fee type is assigned to existing member" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
# Create member without fee type
member =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-03-15]
})
|> Ash.create!()
# Verify no cycles yet
assert get_member_cycles(member.id) == []
# Update to assign fee type
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
cycles = get_member_cycles(member.id)
# Should have generated cycles
assert length(cycles) >= 2
end
end
describe "concurrent cycle generation" do
test "handles multiple members being created concurrently" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
# Create multiple members concurrently
tasks =
Enum.map(1..5, fn i ->
Task.async(fn ->
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test#{i}",
last_name: "User#{i}",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-03-15],
membership_fee_type_id: fee_type.id
})
|> Ash.create!()
end)
end)
members = Enum.map(tasks, &Task.await/1)
# Each member should have cycles
Enum.each(members, fn member ->
cycles = get_member_cycles(member.id)
assert length(cycles) >= 2, "Member #{member.id} should have at least 2 cycles"
end)
end
end
describe "idempotent cycle generation" do
test "running generation multiple times does not create duplicate cycles" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
member =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-03-15],
membership_fee_type_id: fee_type.id
})
|> Ash.create!()
initial_cycles = get_member_cycles(member.id)
initial_count = length(initial_cycles)
# Use a fixed "today" date to avoid date dependency
# Use a date far enough in the future to ensure all cycles are generated
today = ~D[2025-12-31]
# Manually trigger generation again with fixed "today" date
{:ok, _} =
Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id, today: today)
final_cycles = get_member_cycles(member.id)
final_count = length(final_cycles)
# Should have same number of cycles (idempotent)
assert final_count == initial_count
end
end
end