Replace the create_fee_type/create_cycle helpers duplicated across 18/8 membership-fee test files with a single shared definition in Mv.Fixtures, reconciling the divergent local signatures (including the reversed argument order) into one superset so behavior is unchanged.
467 lines
15 KiB
Elixir
467 lines
15 KiB
Elixir
defmodule Mv.MembershipFees.CycleGeneratorTest do
|
|
@moduledoc """
|
|
Tests for the CycleGenerator module.
|
|
"""
|
|
use Mv.DataCase, async: false
|
|
|
|
import Mv.Fixtures, only: [create_fee_type: 2]
|
|
|
|
alias Mv.MembershipFees.CycleGenerator
|
|
alias Mv.MembershipFees.MembershipFeeCycle
|
|
|
|
require Ash.Query
|
|
|
|
setup do
|
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
%{actor: system_actor}
|
|
end
|
|
|
|
# Helper to create a member without triggering cycle generation
|
|
defp create_member_without_cycles(attrs, actor) do
|
|
default_attrs = %{
|
|
first_name: "Test",
|
|
last_name: "User",
|
|
email: "test#{System.unique_integer([:positive])}@example.com"
|
|
}
|
|
|
|
attrs = Map.merge(default_attrs, attrs)
|
|
{:ok, member} = Mv.Membership.create_member(attrs, actor: actor)
|
|
member
|
|
end
|
|
|
|
# Helper to set up settings with specific include_joining_cycle value
|
|
defp setup_settings(include_joining_cycle, actor) do
|
|
{:ok, settings} = Mv.Membership.get_settings()
|
|
|
|
settings
|
|
|> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle})
|
|
|> Ash.update!(actor: actor)
|
|
end
|
|
|
|
# Helper to get cycles for a member
|
|
defp get_member_cycles(member_id, actor) do
|
|
MembershipFeeCycle
|
|
|> Ash.Query.filter(member_id == ^member_id)
|
|
|> Ash.Query.sort(cycle_start: :asc)
|
|
|> Ash.read!(actor: actor)
|
|
end
|
|
|
|
describe "generate_cycles_for_member/2" do
|
|
test "generates cycles from start date to today", %{actor: actor} do
|
|
setup_settings(true, actor)
|
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
|
|
|
# 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]
|
|
},
|
|
actor
|
|
)
|
|
|
|
# Assign fee type
|
|
member =
|
|
member
|
|
|> then(fn m ->
|
|
{:ok, updated} =
|
|
Mv.Membership.update_member(m, %{membership_fee_type_id: fee_type.id}, actor: actor)
|
|
|
|
updated
|
|
end)
|
|
|
|
# Explicitly generate cycles with fixed "today" date to avoid date dependency
|
|
today = ~D[2024-06-15]
|
|
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
|
|
|
# Verify cycles were generated
|
|
all_cycles = get_member_cycles(member.id, actor)
|
|
cycle_years = Enum.map(all_cycles, & &1.cycle_start.year) |> Enum.sort() |> Enum.uniq()
|
|
|
|
# With include_joining_cycle=true and join_date=2022-03-15,
|
|
# start_date should be 2022-01-01
|
|
# Should have cycles for 2022, 2023, 2024
|
|
assert 2022 in cycle_years
|
|
assert 2023 in cycle_years
|
|
assert 2024 in cycle_years
|
|
end
|
|
|
|
test "generates cycles from last existing cycle", %{actor: actor} do
|
|
setup_settings(true, actor)
|
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
|
|
|
# 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]
|
|
},
|
|
actor
|
|
)
|
|
|
|
# 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!(actor: actor)
|
|
|
|
# Now assign fee type to member
|
|
member =
|
|
member
|
|
|> then(fn m ->
|
|
{:ok, updated} =
|
|
Mv.Membership.update_member(m, %{membership_fee_type_id: fee_type.id}, actor: actor)
|
|
|
|
updated
|
|
end)
|
|
|
|
# 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)", %{actor: actor} do
|
|
setup_settings(true, actor)
|
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
|
|
|
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]
|
|
},
|
|
actor
|
|
)
|
|
|
|
# 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)", %{actor: actor} do
|
|
setup_settings(true, actor)
|
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
|
|
|
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]
|
|
},
|
|
actor
|
|
)
|
|
|
|
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 "does not fill gaps when cycles were deleted", %{actor: actor} do
|
|
setup_settings(true, actor)
|
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
|
|
|
# Create member without fee type first to control which cycles exist
|
|
member =
|
|
create_member_without_cycles(
|
|
%{
|
|
join_date: ~D[2020-03-15],
|
|
membership_fee_start_date: ~D[2020-01-01]
|
|
},
|
|
actor
|
|
)
|
|
|
|
# Manually create cycles for 2020, 2021, 2022, 2023
|
|
for year <- [2020, 2021, 2022, 2023] do
|
|
MembershipFeeCycle
|
|
|> Ash.Changeset.for_create(
|
|
:create,
|
|
%{
|
|
cycle_start: Date.new!(year, 1, 1),
|
|
member_id: member.id,
|
|
membership_fee_type_id: fee_type.id,
|
|
amount: fee_type.amount,
|
|
status: :unpaid
|
|
}
|
|
)
|
|
|> Ash.create!(actor: actor)
|
|
end
|
|
|
|
# Delete the 2021 cycle (create a gap)
|
|
cycle_2021 =
|
|
MembershipFeeCycle
|
|
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^~D[2021-01-01])
|
|
|> Ash.read_one!(actor: actor)
|
|
|
|
Ash.destroy!(cycle_2021, actor: actor)
|
|
|
|
# Now assign fee type to member (this triggers generation)
|
|
# Since cycles already exist (2020, 2022, 2023), the generator will
|
|
# start from the last existing cycle (2023) and go forward
|
|
member =
|
|
member
|
|
|> then(fn m ->
|
|
{:ok, updated} =
|
|
Mv.Membership.update_member(m, %{membership_fee_type_id: fee_type.id}, actor: actor)
|
|
|
|
updated
|
|
end)
|
|
|
|
# Verify gap was NOT filled and new cycles were generated from last existing
|
|
all_cycles = get_member_cycles(member.id, actor)
|
|
all_cycle_years = Enum.map(all_cycles, & &1.cycle_start.year) |> Enum.sort()
|
|
|
|
# 2021 should NOT exist (gap was not filled)
|
|
refute 2021 in all_cycle_years, "Gap at 2021 should NOT be filled"
|
|
|
|
# 2020, 2022, 2023 should exist (original cycles)
|
|
assert 2020 in all_cycle_years
|
|
assert 2022 in all_cycle_years
|
|
assert 2023 in all_cycle_years
|
|
|
|
# 2024 and 2025 should exist (generated after last existing cycle 2023)
|
|
assert 2024 in all_cycle_years
|
|
assert 2025 in all_cycle_years
|
|
end
|
|
|
|
test "sets correct amount from membership fee type", %{actor: actor} do
|
|
setup_settings(true, actor)
|
|
amount = Decimal.new("75.50")
|
|
fee_type = create_fee_type(%{interval: :yearly, amount: amount}, actor)
|
|
|
|
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]
|
|
},
|
|
actor
|
|
)
|
|
|
|
# Verify cycles were generated with correct amount
|
|
all_cycles = get_member_cycles(member.id, actor)
|
|
refute Enum.empty?(all_cycles), "Expected cycles to be generated"
|
|
|
|
# All cycles should have the correct amount
|
|
Enum.each(all_cycles, fn cycle ->
|
|
assert Decimal.equal?(cycle.amount, amount)
|
|
end)
|
|
end
|
|
|
|
test "handles NULL membership_fee_start_date by calculating from join_date", %{actor: actor} do
|
|
setup_settings(true, actor)
|
|
fee_type = create_fee_type(%{interval: :quarterly}, actor)
|
|
|
|
# Create member without membership_fee_start_date - it will be auto-calculated
|
|
# and cycles will be auto-generated
|
|
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
|
|
},
|
|
actor
|
|
)
|
|
|
|
# Verify cycles were auto-generated
|
|
all_cycles = get_member_cycles(member.id, actor)
|
|
|
|
# With include_joining_cycle=true and join_date=2024-02-15 (quarterly),
|
|
# start_date should be 2024-01-01 (Q1 start)
|
|
# Should have Q1, Q2, Q3, Q4 2024 cycles (based on current date)
|
|
refute Enum.empty?(all_cycles), "Expected cycles to be generated"
|
|
|
|
cycle_starts = Enum.map(all_cycles, & &1.cycle_start) |> Enum.sort(Date)
|
|
first_cycle_start = List.first(cycle_starts)
|
|
|
|
# First cycle should start in Q1 2024 (2024-01-01)
|
|
assert first_cycle_start == ~D[2024-01-01]
|
|
end
|
|
|
|
test "returns error when member has no membership_fee_type", %{actor: actor} do
|
|
# Create member without fee type - no auto-generation will occur
|
|
member =
|
|
create_member_without_cycles(
|
|
%{
|
|
join_date: ~D[2024-03-15]
|
|
# No membership_fee_type_id
|
|
},
|
|
actor
|
|
)
|
|
|
|
{: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", %{actor: actor} do
|
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
|
|
|
# Create member without join_date - no auto-generation will occur
|
|
# (after_action hook checks for join_date)
|
|
member =
|
|
create_member_without_cycles(
|
|
%{
|
|
membership_fee_type_id: fee_type.id
|
|
# No join_date
|
|
},
|
|
actor
|
|
)
|
|
|
|
{: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", %{actor: actor} do
|
|
setup_settings(true, actor)
|
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
|
|
|
# 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]
|
|
},
|
|
actor
|
|
)
|
|
|
|
_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]
|
|
},
|
|
actor
|
|
)
|
|
|
|
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", %{actor: actor} do
|
|
setup_settings(true, actor)
|
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
|
|
|
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]
|
|
},
|
|
actor
|
|
)
|
|
|
|
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
|