218 lines
8.3 KiB
Elixir
218 lines
8.3 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
|
|
|
|
test "explains that the values refer to the filtered contribution period" do
|
|
# The header badge falls back to a plain "filtered" label whenever no
|
|
# compact short code fits (arbitrary or open-ended ranges); its tooltip
|
|
# must spell out what "filtered" means, not just repeat the range.
|
|
for period <- [
|
|
%{from: nil, to: nil},
|
|
%{from: ~D[2026-02-03], to: ~D[2026-08-17]},
|
|
%{from: ~D[2026-01-01], to: nil}
|
|
] do
|
|
tip = PaymentAging.period_tooltip(period)
|
|
assert tip =~ "refer to the filtered contribution period"
|
|
end
|
|
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), color derived from the shared status variant" do
|
|
badge = PaymentAging.badge(0)
|
|
refute Map.has_key?(badge, :variant)
|
|
assert badge.color_class == "badge-success"
|
|
assert badge.label == "All paid"
|
|
end
|
|
|
|
test "N > 0 renders as N open (error/red), color derived from the shared status variant" do
|
|
badge = PaymentAging.badge(2)
|
|
refute Map.has_key?(badge, :variant)
|
|
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
|