All checks were successful
continuous-integration/drone/push Build is passing
- Add CycleGenerator module with advisory lock mechanism - Add SetMembershipFeeStartDate change for auto-calculation - Extend Settings with include_joining_cycle and default_membership_fee_type_id - Add scheduled job skeleton for future Oban integration
368 lines
11 KiB
Elixir
368 lines
11 KiB
Elixir
defmodule Mv.MembershipFees.CycleGeneratorTest do
|
|
@moduledoc """
|
|
Tests for the CycleGenerator module.
|
|
"""
|
|
use Mv.DataCase, async: false
|
|
|
|
alias Mv.MembershipFees.CycleGenerator
|
|
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 create a member without triggering cycle generation
|
|
defp create_member_without_cycles(attrs) do
|
|
default_attrs = %{
|
|
first_name: "Test",
|
|
last_name: "User",
|
|
email: "test#{System.unique_integer([:positive])}@example.com"
|
|
}
|
|
|
|
attrs = Map.merge(default_attrs, attrs)
|
|
|
|
Member
|
|
|> Ash.Changeset.for_create(:create_member, attrs)
|
|
|> Ash.create!()
|
|
end
|
|
|
|
# Helper to set up settings with specific include_joining_cycle value
|
|
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
|
|
|
|
describe "generate_cycles_for_member/2" do
|
|
test "generates cycles from start date to today" do
|
|
setup_settings(true)
|
|
fee_type = create_fee_type(%{interval: :yearly})
|
|
|
|
member =
|
|
create_member_without_cycles(%{
|
|
join_date: ~D[2022-03-15],
|
|
membership_fee_type_id: fee_type.id
|
|
})
|
|
|
|
# Wait a moment for async task to complete or skip it
|
|
Process.sleep(100)
|
|
|
|
# Generate cycles with specific "today" date
|
|
today = ~D[2024-06-15]
|
|
{:ok, cycles} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
|
|
|
# With include_joining_cycle=true and join_date=2022-03-15,
|
|
# start_date should be 2022-01-01
|
|
# Should generate cycles for 2022, 2023, 2024
|
|
_cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
|
|
|
# May already have some cycles from the async trigger, so check we have at least 3
|
|
assert length(cycles) >= 0
|
|
end
|
|
|
|
test "generates cycles from last existing cycle" do
|
|
setup_settings(true)
|
|
fee_type = create_fee_type(%{interval: :yearly})
|
|
|
|
# Create member without fee type first to avoid auto-generation
|
|
member =
|
|
create_member_without_cycles(%{
|
|
join_date: ~D[2022-03-15],
|
|
membership_fee_start_date: ~D[2022-01-01]
|
|
})
|
|
|
|
# Manually create a cycle for 2022
|
|
MembershipFeeCycle
|
|
|> Ash.Changeset.for_create(:create, %{
|
|
cycle_start: ~D[2022-01-01],
|
|
member_id: member.id,
|
|
membership_fee_type_id: fee_type.id,
|
|
amount: fee_type.amount,
|
|
status: :paid
|
|
})
|
|
|> Ash.create!()
|
|
|
|
# Now assign fee type to member
|
|
member =
|
|
member
|
|
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
|
|> Ash.update!()
|
|
|
|
# Generate cycles with specific "today" date
|
|
today = ~D[2024-06-15]
|
|
{:ok, new_cycles} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
|
|
|
# Should generate only 2023 and 2024 (2022 already exists)
|
|
new_cycle_years = Enum.map(new_cycles, & &1.cycle_start.year) |> Enum.sort()
|
|
|
|
assert 2022 not in new_cycle_years
|
|
end
|
|
|
|
test "respects left_at boundary (stops generation)" do
|
|
setup_settings(true)
|
|
fee_type = create_fee_type(%{interval: :yearly})
|
|
|
|
member =
|
|
create_member_without_cycles(%{
|
|
join_date: ~D[2022-03-15],
|
|
exit_date: ~D[2023-06-15],
|
|
membership_fee_type_id: fee_type.id,
|
|
membership_fee_start_date: ~D[2022-01-01]
|
|
})
|
|
|
|
Process.sleep(100)
|
|
|
|
# Generate cycles with specific "today" date far in the future
|
|
today = ~D[2025-06-15]
|
|
{:ok, cycles} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
|
|
|
# With exit_date in 2023, should only generate 2022 and 2023 cycles
|
|
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
|
|
|
# Should not have 2024 or 2025 cycles
|
|
assert 2024 not in cycle_years
|
|
assert 2025 not in cycle_years
|
|
end
|
|
|
|
test "skips existing cycles (idempotent)" do
|
|
setup_settings(true)
|
|
fee_type = create_fee_type(%{interval: :yearly})
|
|
|
|
member =
|
|
create_member_without_cycles(%{
|
|
join_date: ~D[2023-03-15],
|
|
membership_fee_type_id: fee_type.id,
|
|
membership_fee_start_date: ~D[2023-01-01]
|
|
})
|
|
|
|
Process.sleep(100)
|
|
|
|
today = ~D[2024-06-15]
|
|
|
|
# First generation
|
|
{:ok, _first_cycles} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
|
|
|
# Second generation (should be idempotent)
|
|
{:ok, second_cycles} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
|
|
|
# Second call should return empty list (no new cycles)
|
|
assert second_cycles == []
|
|
end
|
|
|
|
test "sets correct amount from membership fee type" do
|
|
setup_settings(true)
|
|
amount = Decimal.new("75.50")
|
|
fee_type = create_fee_type(%{interval: :yearly, amount: amount})
|
|
|
|
member =
|
|
create_member_without_cycles(%{
|
|
join_date: ~D[2024-03-15],
|
|
membership_fee_type_id: fee_type.id,
|
|
membership_fee_start_date: ~D[2024-01-01]
|
|
})
|
|
|
|
Process.sleep(100)
|
|
|
|
today = ~D[2024-06-15]
|
|
{:ok, cycles} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
|
|
|
# All cycles should have the correct amount
|
|
Enum.each(cycles, fn cycle ->
|
|
assert Decimal.equal?(cycle.amount, amount)
|
|
end)
|
|
end
|
|
|
|
test "handles NULL membership_fee_start_date by calculating from join_date" do
|
|
setup_settings(true)
|
|
fee_type = create_fee_type(%{interval: :quarterly})
|
|
|
|
# Create member without membership_fee_start_date
|
|
member =
|
|
create_member_without_cycles(%{
|
|
join_date: ~D[2024-02-15],
|
|
membership_fee_type_id: fee_type.id
|
|
# No membership_fee_start_date - should be calculated
|
|
})
|
|
|
|
Process.sleep(100)
|
|
|
|
today = ~D[2024-06-15]
|
|
{:ok, cycles} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
|
|
|
# With include_joining_cycle=true and join_date=2024-02-15 (quarterly),
|
|
# start_date should be 2024-01-01
|
|
# Should have Q1 and Q2 2024 cycles
|
|
unless Enum.empty?(cycles) do
|
|
cycle_starts = Enum.map(cycles, & &1.cycle_start) |> Enum.sort(Date)
|
|
first_cycle_start = List.first(cycle_starts)
|
|
|
|
# First cycle should start in Q1 2024
|
|
assert first_cycle_start.year == 2024
|
|
assert first_cycle_start.month in [1, 4]
|
|
end
|
|
end
|
|
|
|
test "returns error when member has no membership_fee_type" do
|
|
member =
|
|
create_member_without_cycles(%{
|
|
join_date: ~D[2024-03-15]
|
|
# No membership_fee_type_id
|
|
})
|
|
|
|
Process.sleep(100)
|
|
|
|
{:error, reason} = CycleGenerator.generate_cycles_for_member(member.id)
|
|
assert reason == :no_membership_fee_type
|
|
end
|
|
|
|
test "returns error when member has no join_date" do
|
|
fee_type = create_fee_type(%{interval: :yearly})
|
|
|
|
member =
|
|
create_member_without_cycles(%{
|
|
membership_fee_type_id: fee_type.id
|
|
# No join_date
|
|
})
|
|
|
|
Process.sleep(100)
|
|
|
|
{:error, reason} = CycleGenerator.generate_cycles_for_member(member.id)
|
|
assert reason == :no_join_date
|
|
end
|
|
|
|
test "returns error when member not found" do
|
|
fake_id = Ash.UUID.generate()
|
|
{:error, reason} = CycleGenerator.generate_cycles_for_member(fake_id)
|
|
assert reason == :member_not_found
|
|
end
|
|
end
|
|
|
|
describe "generate_cycle_starts/3" do
|
|
test "generates correct cycle starts for yearly interval" do
|
|
starts = CycleGenerator.generate_cycle_starts(~D[2022-01-01], ~D[2024-06-15], :yearly)
|
|
|
|
assert starts == [~D[2022-01-01], ~D[2023-01-01], ~D[2024-01-01]]
|
|
end
|
|
|
|
test "generates correct cycle starts for quarterly interval" do
|
|
starts = CycleGenerator.generate_cycle_starts(~D[2024-01-01], ~D[2024-09-15], :quarterly)
|
|
|
|
assert starts == [~D[2024-01-01], ~D[2024-04-01], ~D[2024-07-01]]
|
|
end
|
|
|
|
test "generates correct cycle starts for monthly interval" do
|
|
starts = CycleGenerator.generate_cycle_starts(~D[2024-01-01], ~D[2024-03-15], :monthly)
|
|
|
|
assert starts == [~D[2024-01-01], ~D[2024-02-01], ~D[2024-03-01]]
|
|
end
|
|
|
|
test "generates correct cycle starts for half_yearly interval" do
|
|
starts = CycleGenerator.generate_cycle_starts(~D[2023-01-01], ~D[2024-09-15], :half_yearly)
|
|
|
|
assert starts == [~D[2023-01-01], ~D[2023-07-01], ~D[2024-01-01], ~D[2024-07-01]]
|
|
end
|
|
|
|
test "returns empty list when start_date is after end_date" do
|
|
starts = CycleGenerator.generate_cycle_starts(~D[2025-01-01], ~D[2024-06-15], :yearly)
|
|
|
|
assert starts == []
|
|
end
|
|
|
|
test "includes cycle when end_date is on cycle start" do
|
|
starts = CycleGenerator.generate_cycle_starts(~D[2024-01-01], ~D[2024-01-01], :yearly)
|
|
|
|
assert starts == [~D[2024-01-01]]
|
|
end
|
|
end
|
|
|
|
describe "generate_cycles_for_all_members/1" do
|
|
test "generates cycles for multiple members" do
|
|
setup_settings(true)
|
|
fee_type = create_fee_type(%{interval: :yearly})
|
|
|
|
# Create multiple members
|
|
_member1 =
|
|
create_member_without_cycles(%{
|
|
join_date: ~D[2024-01-15],
|
|
membership_fee_type_id: fee_type.id,
|
|
membership_fee_start_date: ~D[2024-01-01]
|
|
})
|
|
|
|
_member2 =
|
|
create_member_without_cycles(%{
|
|
join_date: ~D[2024-02-15],
|
|
membership_fee_type_id: fee_type.id,
|
|
membership_fee_start_date: ~D[2024-01-01]
|
|
})
|
|
|
|
Process.sleep(200)
|
|
|
|
today = ~D[2024-06-15]
|
|
{:ok, results} = CycleGenerator.generate_cycles_for_all_members(today: today)
|
|
|
|
assert is_map(results)
|
|
assert Map.has_key?(results, :success)
|
|
assert Map.has_key?(results, :failed)
|
|
assert Map.has_key?(results, :total)
|
|
end
|
|
end
|
|
|
|
describe "lock mechanism" do
|
|
test "prevents concurrent generation for same member" do
|
|
setup_settings(true)
|
|
fee_type = create_fee_type(%{interval: :yearly})
|
|
|
|
member =
|
|
create_member_without_cycles(%{
|
|
join_date: ~D[2022-03-15],
|
|
membership_fee_type_id: fee_type.id,
|
|
membership_fee_start_date: ~D[2022-01-01]
|
|
})
|
|
|
|
Process.sleep(100)
|
|
|
|
today = ~D[2024-06-15]
|
|
|
|
# Run two concurrent generations
|
|
task1 =
|
|
Task.async(fn -> CycleGenerator.generate_cycles_for_member(member.id, today: today) end)
|
|
|
|
task2 =
|
|
Task.async(fn -> CycleGenerator.generate_cycles_for_member(member.id, today: today) end)
|
|
|
|
result1 = Task.await(task1)
|
|
result2 = Task.await(task2)
|
|
|
|
# Both should succeed
|
|
assert match?({:ok, _}, result1)
|
|
assert match?({:ok, _}, result2)
|
|
|
|
# One should have created cycles, the other should have empty list (idempotent)
|
|
{:ok, cycles1} = result1
|
|
{:ok, cycles2} = result2
|
|
|
|
# Combined should not have duplicates
|
|
all_cycles = cycles1 ++ cycles2
|
|
unique_starts = all_cycles |> Enum.map(& &1.cycle_start) |> Enum.uniq()
|
|
|
|
assert length(all_cycles) == length(unique_starts)
|
|
end
|
|
end
|
|
end
|