mitgliederverwaltung/test/mv_web/member_live/index_payment_period_test.exs

204 lines
7.6 KiB
Elixir

defmodule MvWeb.MemberLive.IndexPaymentPeriodTest do
@moduledoc """
§1.13 / §1.14 / §3.3 — the PaymentAging module: period parse/serialize with
the all-outstanding default, badge formatting, and the open-cycles list that
backs the badge tooltip (unpaid cycles for the period; suspended shown apart).
"""
use Mv.DataCase, async: false
use ExUnitProperties
import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4]
alias MvWeb.MemberLive.Index.PaymentAging
require Ash.Query
describe "period parsing / serialization" do
test "default period is all-outstanding (both bounds nil)" do
assert PaymentAging.default_period() == %{from: nil, to: nil}
end
test "no params parses to the default period" do
assert PaymentAging.parse_period(%{}) == %{from: nil, to: nil}
end
test "parses ISO bounds and round-trips through to_params" do
period = %{from: ~D[2024-01-01], to: ~D[2024-12-31]}
params = PaymentAging.to_params(period)
assert params["pay_from"] == "2024-01-01"
assert params["pay_to"] == "2024-12-31"
assert PaymentAging.parse_period(params) == period
end
test "default period serializes to no params" do
assert PaymentAging.to_params(PaymentAging.default_period()) == %{}
end
test "malformed bounds fall back to nil" do
assert PaymentAging.parse_period(%{"pay_from" => "not-a-date"}) == %{from: nil, to: nil}
end
end
describe "period short code (§1.28)" do
test "the all-outstanding default has no short code" do
assert PaymentAging.period_short_code(%{from: nil, to: nil}) == nil
end
test "a full calendar year renders as the year" do
assert PaymentAging.period_short_code(%{from: ~D[2026-01-01], to: ~D[2026-12-31]}) == "2026"
end
test "a full calendar quarter renders as Q# YYYY" do
assert PaymentAging.period_short_code(%{from: ~D[2026-01-01], to: ~D[2026-03-31]}) ==
"Q1 2026"
assert PaymentAging.period_short_code(%{from: ~D[2026-10-01], to: ~D[2026-12-31]}) ==
"Q4 2026"
end
test "an arbitrary bounded period has no compact short code" do
assert PaymentAging.period_short_code(%{from: ~D[2026-02-03], to: ~D[2026-08-17]}) == nil
# An open-ended period is not compactly codeable either.
assert PaymentAging.period_short_code(%{from: ~D[2026-01-01], to: nil}) == nil
end
end
describe "period tooltip (§1.28)" do
test "names the period with locally formatted bounds" do
tip = PaymentAging.period_tooltip(%{from: ~D[2026-01-01], to: ~D[2026-12-31]})
assert tip =~ "01.01.2026"
assert tip =~ "31.12.2026"
end
test "the all-outstanding default reads as all outstanding" do
tip = PaymentAging.period_tooltip(%{from: nil, to: nil})
assert is_binary(tip) and tip =~ "outstanding"
end
end
describe "payment-count filter codec" do
test "round-trips fully_paid and has-unpaid thresholds" do
for state <- [:fully_paid, {:has_unpaid, 1}, {:has_unpaid, 2}, {:has_unpaid, 3}] do
params = PaymentAging.filter_to_params(state)
assert PaymentAging.parse_filter_params(params) == state
end
end
test "nil filter serializes to no params and unknown values parse to nil" do
assert PaymentAging.filter_to_params(nil) == %{}
assert PaymentAging.parse_filter_params(%{}) == nil
assert PaymentAging.parse_filter_params(%{"pay_filter" => "bogus"}) == nil
end
test "serializes to stable string tokens" do
assert PaymentAging.filter_to_params(:fully_paid) == %{"pay_filter" => "fully_paid"}
assert PaymentAging.filter_to_params({:has_unpaid, 2}) == %{"pay_filter" => "unpaid_2"}
end
end
describe "payment URL round-trip (§2.1, payment keys)" do
@dates [nil, ~D[2020-01-01], ~D[2024-06-30], ~D[2025-12-31]]
@filters [nil, :fully_paid, {:has_unpaid, 1}, {:has_unpaid, 2}, {:has_unpaid, 3}]
property "decode∘encode is canonical and idempotent for period + payment filter" do
check all(
from <- StreamData.member_of(@dates),
to <- StreamData.member_of(@dates),
filter <- StreamData.member_of(@filters)
) do
period = %{from: from, to: to}
params =
period
|> PaymentAging.to_params()
|> Map.merge(PaymentAging.filter_to_params(filter))
assert PaymentAging.parse_period(params) == period
assert PaymentAging.parse_filter_params(params) == filter
# Idempotence: re-encoding the decoded state yields the same params.
reencoded =
params
|> PaymentAging.parse_period()
|> PaymentAging.to_params()
|> Map.merge(PaymentAging.filter_to_params(PaymentAging.parse_filter_params(params)))
assert reencoded == params
end
end
end
describe "badge/1" do
test "0 renders as All paid (success/green)" do
badge = PaymentAging.badge(0)
assert badge.variant == :success
assert badge.color_class == "badge-success"
assert badge.label == "All paid"
end
test "N > 0 renders as N open (error/red)" do
badge = PaymentAging.badge(2)
assert badge.variant == :error
assert badge.color_class == "badge-error"
assert badge.label =~ "2"
assert badge.label =~ "open"
end
end
describe "open_cycles/2" do
setup do
actor = Mv.Helpers.SystemActor.get_system_actor()
ft = create_fee_type(%{interval: :monthly}, actor)
%{actor: actor, ft: ft}
end
test "lists in-period unpaid and suspended cycles chronologically; paid/out-of-period excluded",
%{actor: actor, ft: ft} do
member = member_fixture_with_actor(%{}, actor)
create_cycle(member, ft, %{cycle_start: ~D[2024-02-01], status: :unpaid}, actor)
create_cycle(member, ft, %{cycle_start: ~D[2024-01-01], status: :unpaid}, actor)
create_cycle(member, ft, %{cycle_start: ~D[2024-03-01], status: :suspended}, actor)
create_cycle(member, ft, %{cycle_start: ~D[2024-04-01], status: :paid}, actor)
# out of period
create_cycle(member, ft, %{cycle_start: ~D[2023-01-01], status: :unpaid}, actor)
member = Ash.load!(member, :membership_fee_cycles, actor: actor)
result = PaymentAging.open_cycles(member, %{from: ~D[2024-01-01], to: ~D[2024-12-31]})
assert Enum.map(result, & &1.status) == [:unpaid, :unpaid, :suspended]
assert Enum.map(result, & &1.cycle_start) == [
~D[2024-01-01],
~D[2024-02-01],
~D[2024-03-01]
]
end
test "nil bounds include every unpaid/suspended cycle", %{actor: actor, ft: ft} do
member = member_fixture_with_actor(%{}, actor)
create_cycle(member, ft, %{cycle_start: ~D[2020-01-01], status: :unpaid}, actor)
create_cycle(member, ft, %{cycle_start: ~D[2026-01-01], status: :suspended}, actor)
member = Ash.load!(member, :membership_fee_cycles, actor: actor)
result = PaymentAging.open_cycles(member, PaymentAging.default_period())
assert Enum.map(result, & &1.status) == [:unpaid, :suspended]
end
end
describe "tooltip_cycles/1 (five-slot cap)" do
test "five or fewer cycles show in full with no overflow" do
cycles = for i <- 1..5, do: %{n: i}
assert PaymentAging.tooltip_cycles(cycles) == {cycles, 0}
end
test "more than five collapses to first four cycles plus the overflow count" do
cycles = for i <- 1..19, do: %{n: i}
{shown, overflow} = PaymentAging.tooltip_cycles(cycles)
assert shown == Enum.take(cycles, 4)
assert overflow == 15
end
end
end