defmodule Mv.MembershipFees.CycleEndTest do @moduledoc """ Tests for the denormalized `cycle_end` column on membership fee cycles: the create-time change that sets it, and the one-time backfill helper. """ use Mv.DataCase, async: false import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2] alias Mv.MembershipFees.CalendarCycles alias Mv.MembershipFees.CycleEndBackfill alias Mv.MembershipFees.MembershipFeeCycle setup do %{actor: Mv.Helpers.SystemActor.get_system_actor()} end defp create_member(fee_type, actor) do member_fixture_with_actor(%{membership_fee_type_id: fee_type.id}, actor) end describe "create sets cycle_end" do for interval <- [:monthly, :quarterly, :half_yearly, :yearly] do test "create sets cycle_end from cycle_start + #{interval} interval", %{actor: actor} do interval = unquote(interval) fee_type = create_fee_type(%{interval: interval}, actor) member = create_member(fee_type, actor) cycle_start = ~D[2024-02-01] cycle = MembershipFeeCycle |> Ash.Changeset.for_create(:create, %{ cycle_start: cycle_start, amount: Decimal.new("50.00"), member_id: member.id, membership_fee_type_id: fee_type.id }) |> Ash.create!(actor: actor) assert cycle.cycle_end == CalendarCycles.calculate_cycle_end(cycle_start, interval) end end end describe "create with interval argument" do test "uses the passed interval and skips the fee-type lookup", %{actor: actor} do # Fee type carries :yearly, but the caller passes :monthly explicitly. # If the interval argument is honored (no per-row fee-type lookup), the # stored cycle_end reflects the argument, not the fee type's interval. fee_type = create_fee_type(%{interval: :yearly}, actor) member = create_member(fee_type, actor) cycle_start = ~D[2024-02-01] cycle = MembershipFeeCycle |> Ash.Changeset.for_create(:create, %{ cycle_start: cycle_start, amount: Decimal.new("50.00"), member_id: member.id, membership_fee_type_id: fee_type.id, interval: :monthly }) |> Ash.create!(actor: actor) assert cycle.cycle_end == CalendarCycles.calculate_cycle_end(cycle_start, :monthly) end end describe "backfill" do test "fills cycle_end for pre-existing rows matching calculate_cycle_end/2", %{actor: actor} do fee_type = create_fee_type(%{interval: :quarterly}, actor) member = create_member(fee_type, actor) cycle = MembershipFeeCycle |> Ash.Changeset.for_create(:create, %{ cycle_start: ~D[2024-04-01], amount: Decimal.new("50.00"), member_id: member.id, membership_fee_type_id: fee_type.id }) |> Ash.create!(actor: actor) # Simulate a pre-existing row that predates the cycle_end column. Repo.query!("ALTER TABLE membership_fee_cycles ALTER COLUMN cycle_end DROP NOT NULL") Repo.query!("UPDATE membership_fee_cycles SET cycle_end = NULL WHERE id = $1", [ Ecto.UUID.dump!(cycle.id) ]) assert {:ok, count} = CycleEndBackfill.run() assert count >= 1 reloaded = Ash.get!(MembershipFeeCycle, cycle.id, actor: actor) assert reloaded.cycle_end == CalendarCycles.calculate_cycle_end(~D[2024-04-01], :quarterly) end end end