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