mitgliederverwaltung/test/membership_fees/cycle_end_test.exs
Simon b745b13ca5 feat(membership-fees): denormalize cycle_end so fee status filters run in the database
Store the cycle's end date alongside its start so the payment-status
filter can be a plain date comparison in SQL instead of loading every
cycle and classifying it in memory. The value is fixed when the cycle
is created (the fee type's interval is immutable and the update action
never changes the start date), so the denormalized column can never
drift from the computed end and needs no write-time reconciliation.
2026-07-03 11:04:43 +02:00

98 lines
3.4 KiB
Elixir

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