All checks were successful
continuous-integration/drone/push Build is passing
- 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
211 lines
6.2 KiB
Elixir
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
|