feat(overview): add point-in-time, suspended, OR-group and count-range filters
This commit is contained in:
parent
c547ca19af
commit
c0d1550401
23 changed files with 1116 additions and 117 deletions
66
test/mv_web/member_live/index_date_presets_property_test.exs
Normal file
66
test/mv_web/member_live/index_date_presets_property_test.exs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
defmodule MvWeb.MemberLive.IndexDatePresetsPropertyTest do
|
||||
@moduledoc """
|
||||
§2.8 — Relative date-range preset resolution.
|
||||
|
||||
For any preset evaluated against any reference date "today", the resolved
|
||||
`{from, to}` matches the preset's definition: rolling "last N days" windows end
|
||||
on today, and the "this month/quarter/year" presets run period-to-date from the
|
||||
respective period start to today.
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnitProperties
|
||||
|
||||
alias MvWeb.MemberLive.Index.DatePresets
|
||||
|
||||
defp date_gen do
|
||||
gen all(
|
||||
year <- StreamData.integer(2000..2100),
|
||||
month <- StreamData.integer(1..12),
|
||||
day <- StreamData.integer(1..28)
|
||||
) do
|
||||
Date.new!(year, month, day)
|
||||
end
|
||||
end
|
||||
|
||||
property "last-N-days presets end on today and span N days inclusive" do
|
||||
check all(today <- date_gen()) do
|
||||
assert DatePresets.resolve(:last_7_days, today) == %{from: Date.add(today, -6), to: today}
|
||||
assert DatePresets.resolve(:last_30_days, today) == %{from: Date.add(today, -29), to: today}
|
||||
end
|
||||
end
|
||||
|
||||
property "last-N-months presets shift the start back N months and end on today" do
|
||||
check all(today <- date_gen()) do
|
||||
assert DatePresets.resolve(:last_3_months, today) ==
|
||||
%{from: Date.shift(today, month: -3), to: today}
|
||||
|
||||
assert DatePresets.resolve(:last_12_months, today) ==
|
||||
%{from: Date.shift(today, month: -12), to: today}
|
||||
end
|
||||
end
|
||||
|
||||
property "this-period presets run from the period start to today" do
|
||||
check all(today <- date_gen()) do
|
||||
month_start = %{today | day: 1}
|
||||
q_first_month = div(today.month - 1, 3) * 3 + 1
|
||||
quarter_start = Date.new!(today.year, q_first_month, 1)
|
||||
year_start = Date.new!(today.year, 1, 1)
|
||||
|
||||
assert DatePresets.resolve(:this_month, today) == %{from: month_start, to: today}
|
||||
assert DatePresets.resolve(:this_quarter, today) == %{from: quarter_start, to: today}
|
||||
assert DatePresets.resolve(:this_year, today) == %{from: year_start, to: today}
|
||||
end
|
||||
end
|
||||
|
||||
test "all/0 lists every resolvable preset key in display order" do
|
||||
assert DatePresets.all() == [
|
||||
:last_7_days,
|
||||
:last_30_days,
|
||||
:last_3_months,
|
||||
:last_12_months,
|
||||
:this_month,
|
||||
:this_quarter,
|
||||
:this_year
|
||||
]
|
||||
end
|
||||
end
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
defmodule MvWeb.MemberLive.IndexDatePresetsTest do
|
||||
@moduledoc """
|
||||
§1.8 — a date field's value control offers presets; picking "This year"
|
||||
applies the Jan 1 – Dec 31 range of the current year and writes the equivalent
|
||||
range params to the URL.
|
||||
§1.25 (supersedes §1.8) — a date field's value control offers relative
|
||||
presets; picking "This year" applies the period-to-date range (Jan 1 – today)
|
||||
of the current year and writes the equivalent range params to the URL.
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
|
||||
|
|
@ -20,7 +20,7 @@ defmodule MvWeb.MemberLive.IndexDatePresetsTest do
|
|||
%{conn: conn_with_oidc_user(conn)}
|
||||
end
|
||||
|
||||
test "the 'This year' preset applies the current-year range and writes URL params", %{
|
||||
test "the 'This year' preset applies the year-to-date range and writes URL params", %{
|
||||
conn: conn
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/members")
|
||||
|
|
@ -32,8 +32,20 @@ defmodule MvWeb.MemberLive.IndexDatePresetsTest do
|
|||
view |> element("[data-testid='date-preset-this_year']") |> render_click()
|
||||
|
||||
path = assert_patch(view)
|
||||
year = Date.utc_today().year
|
||||
assert path =~ "jd_from=#{year}-01-01"
|
||||
assert path =~ "jd_to=#{year}-12-31"
|
||||
today = Date.utc_today()
|
||||
assert path =~ "jd_from=#{today.year}-01-01"
|
||||
assert path =~ "jd_to=#{Date.to_iso8601(today)}"
|
||||
end
|
||||
|
||||
test "the 'Last 30 days' preset applies a today-anchored 30-day window", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/members")
|
||||
|
||||
pick_field(view, "join_date")
|
||||
view |> element("[data-testid='date-preset-last_30_days']") |> render_click()
|
||||
|
||||
path = assert_patch(view)
|
||||
today = Date.utc_today()
|
||||
assert path =~ "jd_from=#{Date.to_iso8601(Date.add(today, -29))}"
|
||||
assert path =~ "jd_to=#{Date.to_iso8601(today)}"
|
||||
end
|
||||
end
|
||||
|
|
|
|||
38
test/mv_web/member_live/index_filter_url_roundtrip_test.exs
Normal file
38
test/mv_web/member_live/index_filter_url_roundtrip_test.exs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
defmodule MvWeb.MemberLive.IndexFilterUrlRoundtripTest do
|
||||
@moduledoc """
|
||||
§2.1 (extended) — the v2 filter keys survive the LiveView decode→encode round
|
||||
trip. Loading `/members` with a Stichtag date, the suspended flag, and a
|
||||
two-sided payment count range, then triggering an unrelated re-patch (a sort
|
||||
click), preserves every one of those keys in the pushed URL.
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
setup %{conn: conn} do
|
||||
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, _member} =
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Round", last_name: "Trip", email: "roundtrip@example.com"},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
%{conn: conn_with_oidc_user(conn)}
|
||||
end
|
||||
|
||||
test "stichtag, suspended and payment count range persist through a re-patch", %{conn: conn} do
|
||||
url = "/members?stichtag=2026-03-15&suspended=1&pay_filter=has_unpaid&pay_min=2&pay_max=4"
|
||||
{:ok, view, _html} = live(conn, url)
|
||||
|
||||
# Trigger an unrelated URL re-patch by sorting a stable column.
|
||||
view |> element("[data-testid='payment']") |> render_click()
|
||||
path = assert_patch(view)
|
||||
|
||||
assert path =~ "stichtag=2026-03-15"
|
||||
assert path =~ "suspended=1"
|
||||
assert path =~ "pay_filter=has_unpaid"
|
||||
assert path =~ "pay_min=2"
|
||||
assert path =~ "pay_max=4"
|
||||
end
|
||||
end
|
||||
72
test/mv_web/member_live/index_group_or_test.exs
Normal file
72
test/mv_web/member_live/index_group_or_test.exs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
defmodule MvWeb.MemberLive.IndexGroupOrTest do
|
||||
@moduledoc """
|
||||
§1.29 / §2.7 — Multi-select group semantics is OR.
|
||||
|
||||
Two "is" group selections match members in *either* group (union). Two
|
||||
"is not" selections exclude members in *any* of them. Verified at the
|
||||
`OverviewQuery` layer where the semantics live.
|
||||
"""
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
import Mv.Fixtures, only: [member_fixture_with_actor: 2, group_fixture: 1]
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.Membership.MemberGroup
|
||||
alias MvWeb.MemberLive.Index.OverviewQuery
|
||||
|
||||
setup do
|
||||
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor))
|
||||
|
||||
g1 = group_fixture(%{name: "G1-#{System.unique_integer([:positive])}"})
|
||||
g2 = group_fixture(%{name: "G2-#{System.unique_integer([:positive])}"})
|
||||
|
||||
m1 = member_fixture_with_actor(%{}, actor)
|
||||
m2 = member_fixture_with_actor(%{}, actor)
|
||||
m3 = member_fixture_with_actor(%{}, actor)
|
||||
|
||||
add_to_group(m1, g1, actor)
|
||||
add_to_group(m2, g2, actor)
|
||||
|
||||
%{actor: actor, g1: g1, g2: g2, m1: m1, m2: m2, m3: m3}
|
||||
end
|
||||
|
||||
defp add_to_group(member, group, actor) do
|
||||
MemberGroup
|
||||
|> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id})
|
||||
|> Ash.create!(actor: actor)
|
||||
end
|
||||
|
||||
defp ids(opts, actor) do
|
||||
opts
|
||||
|> OverviewQuery.build()
|
||||
|> Ash.read!(actor: actor)
|
||||
|> MapSet.new(& &1.id)
|
||||
end
|
||||
|
||||
test "two 'is' group selections match members in either group (OR)", ctx do
|
||||
result =
|
||||
ids(
|
||||
%{
|
||||
group_filters: %{to_string(ctx.g1.id) => :in, to_string(ctx.g2.id) => :in},
|
||||
groups: [ctx.g1, ctx.g2]
|
||||
},
|
||||
ctx.actor
|
||||
)
|
||||
|
||||
assert MapSet.equal?(result, MapSet.new([ctx.m1.id, ctx.m2.id]))
|
||||
end
|
||||
|
||||
test "two 'is not' group selections exclude members in any of them", ctx do
|
||||
result =
|
||||
ids(
|
||||
%{
|
||||
group_filters: %{to_string(ctx.g1.id) => :not_in, to_string(ctx.g2.id) => :not_in},
|
||||
groups: [ctx.g1, ctx.g2]
|
||||
},
|
||||
ctx.actor
|
||||
)
|
||||
|
||||
assert MapSet.equal?(result, MapSet.new([ctx.m3.id]))
|
||||
end
|
||||
end
|
||||
|
|
@ -3,8 +3,8 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
|
|||
Tests for filtering members by group in the member overview.
|
||||
|
||||
Uses the filter dropdown (MemberFilterComponent) with one row per group:
|
||||
All / Yes / No (per group). Multiple active group filters combine with AND
|
||||
(member must match all selected group conditions).
|
||||
All / Yes / No (per group). Multiple "is" selections combine with OR (union),
|
||||
while each "is not" selection excludes members in that group (§1.29/§2.7).
|
||||
"""
|
||||
# Kept async: false as a deferred scope decision. The deferrable-FK migration
|
||||
# removed the concurrent-create_member deadlock that previously forced this, so
|
||||
|
|
|
|||
41
test/mv_web/member_live/index_payment_count_codec_test.exs
Normal file
41
test/mv_web/member_live/index_payment_count_codec_test.exs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
defmodule MvWeb.MemberLive.IndexPaymentCountCodecTest do
|
||||
@moduledoc """
|
||||
§1.22 / §3.14 — URL codec for the two-sided payment open-cycle count range.
|
||||
|
||||
The `{:unpaid_range, min, max}` filter round-trips through the flat URL
|
||||
contract: encoding to params then decoding yields the canonical filter, with
|
||||
the "has open" default (`min=1`, `max=nil`) and both-bounded ranges covered.
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias MvWeb.MemberLive.Index.PaymentAging
|
||||
|
||||
defp roundtrip(filter),
|
||||
do: filter |> PaymentAging.filter_to_params() |> PaymentAging.parse_filter_params()
|
||||
|
||||
test "default has-open range (min 1, unbounded above) round-trips" do
|
||||
assert roundtrip({:unpaid_range, 1, nil}) == {:unpaid_range, 1, nil}
|
||||
end
|
||||
|
||||
test "two-sided range round-trips" do
|
||||
assert roundtrip({:unpaid_range, 2, 5}) == {:unpaid_range, 2, 5}
|
||||
end
|
||||
|
||||
test "exact single-value range round-trips" do
|
||||
assert roundtrip({:unpaid_range, 3, 3}) == {:unpaid_range, 3, 3}
|
||||
end
|
||||
|
||||
test "has_unpaid params decode into an unpaid_range" do
|
||||
params = %{"pay_filter" => "has_unpaid", "pay_min" => "2", "pay_max" => "4"}
|
||||
assert PaymentAging.parse_filter_params(params) == {:unpaid_range, 2, 4}
|
||||
end
|
||||
|
||||
test "has_unpaid with no bounds defaults to min 1, max nil" do
|
||||
assert PaymentAging.parse_filter_params(%{"pay_filter" => "has_unpaid"}) ==
|
||||
{:unpaid_range, 1, nil}
|
||||
end
|
||||
|
||||
test "fully_paid still round-trips" do
|
||||
assert roundtrip(:fully_paid) == :fully_paid
|
||||
end
|
||||
end
|
||||
|
|
@ -66,6 +66,24 @@ defmodule MvWeb.MemberLive.IndexPaymentFilterTest do
|
|||
refute MapSet.member?(ids({:has_unpaid, 1}, actor), zero.id)
|
||||
end
|
||||
|
||||
test "unpaid-range selects members whose in-period count is within [min, max]", %{
|
||||
actor: actor,
|
||||
ft: ft
|
||||
} do
|
||||
zero = member_with_unpaid(0, actor, ft)
|
||||
one = member_with_unpaid(1, actor, ft)
|
||||
two = member_with_unpaid(2, actor, ft)
|
||||
three = member_with_unpaid(3, actor, ft)
|
||||
|
||||
# two-sided: 2..3
|
||||
assert ids({:unpaid_range, 2, 3}, actor) == MapSet.new([two.id, three.id])
|
||||
# lower-bounded only: >= 1 (max nil)
|
||||
assert ids({:unpaid_range, 1, nil}, actor) == MapSet.new([one.id, two.id, three.id])
|
||||
# exact single value: 1..1
|
||||
assert ids({:unpaid_range, 1, 1}, actor) == MapSet.new([one.id])
|
||||
refute MapSet.member?(ids({:unpaid_range, 1, nil}, actor), zero.id)
|
||||
end
|
||||
|
||||
test "suspended cycles do not count toward has-unpaid", %{actor: actor, ft: ft} do
|
||||
member = member_fixture_with_actor(%{}, actor)
|
||||
create_cycle(member, ft, %{cycle_start: ~D[2024-01-01], status: :suspended}, actor)
|
||||
|
|
|
|||
|
|
@ -40,6 +40,43 @@ defmodule MvWeb.MemberLive.IndexPaymentPeriodTest do
|
|||
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
|
||||
|
|
|
|||
85
test/mv_web/member_live/index_payment_sort_test.exs
Normal file
85
test/mv_web/member_live/index_payment_sort_test.exs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
defmodule MvWeb.MemberLive.IndexPaymentSortTest do
|
||||
@moduledoc """
|
||||
§1.27 — The Beiträge (payment) column sorts DB-side by the period-scoped
|
||||
unpaid-cycle count, ascending and descending, with the unique id tie-breaker
|
||||
preserving keyset stability. Verified at the `OverviewQuery` layer.
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4]
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias MvWeb.MemberLive.Index.OverviewQuery
|
||||
|
||||
@period %{from: ~D[2024-01-01], to: ~D[2024-12-31]}
|
||||
|
||||
setup do
|
||||
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
ft = create_fee_type(%{interval: :monthly}, actor)
|
||||
Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor))
|
||||
%{actor: actor, ft: ft}
|
||||
end
|
||||
|
||||
defp member_with_unpaid(n, ctx) do
|
||||
member = member_fixture_with_actor(%{}, ctx.actor)
|
||||
|
||||
Enum.each(1..max(n, 0)//1, fn i ->
|
||||
create_cycle(
|
||||
member,
|
||||
ctx.ft,
|
||||
%{cycle_start: Date.new!(2024, i, 1), status: :unpaid},
|
||||
ctx.actor
|
||||
)
|
||||
end)
|
||||
|
||||
member
|
||||
end
|
||||
|
||||
defp sorted_ids(order, ctx) do
|
||||
%{sort_field: :payment, sort_order: order, payment_period: @period}
|
||||
|> OverviewQuery.build()
|
||||
|> Ash.read!(actor: ctx.actor)
|
||||
|> Enum.map(& &1.id)
|
||||
end
|
||||
|
||||
test "sorts ascending by in-period unpaid count", ctx do
|
||||
zero = member_with_unpaid(0, ctx)
|
||||
one = member_with_unpaid(1, ctx)
|
||||
two = member_with_unpaid(2, ctx)
|
||||
|
||||
assert sorted_ids(:asc, ctx) == [zero.id, one.id, two.id]
|
||||
end
|
||||
|
||||
test "sorts descending by in-period unpaid count", ctx do
|
||||
zero = member_with_unpaid(0, ctx)
|
||||
one = member_with_unpaid(1, ctx)
|
||||
two = member_with_unpaid(2, ctx)
|
||||
|
||||
assert sorted_ids(:desc, ctx) == [two.id, one.id, zero.id]
|
||||
end
|
||||
|
||||
describe "payment sort header (LiveView)" do
|
||||
setup %{conn: conn} do
|
||||
%{conn: conn_with_oidc_user(conn)}
|
||||
end
|
||||
|
||||
@tag :ui
|
||||
test "clicking the payment header reflects the sort in the URL", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/members")
|
||||
|
||||
view |> element("[data-testid='payment']") |> render_click()
|
||||
|
||||
path = assert_patch(view)
|
||||
assert path =~ "sort_field=payment"
|
||||
assert path =~ "sort_order=asc"
|
||||
end
|
||||
|
||||
@tag :ui
|
||||
test "the payment th exposes aria-sort when active", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/members?sort_field=payment&sort_order=desc")
|
||||
|
||||
assert has_element?(view, "th[aria-sort='descending']")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -69,4 +69,25 @@ defmodule MvWeb.MemberLive.IndexPaymentWiringTest do
|
|||
{:ok, all_time, _} = live(ctx.conn, "/members?pay_filter=unpaid_1")
|
||||
assert has_element?(all_time, "#row-#{member.id}")
|
||||
end
|
||||
|
||||
describe "period indicator badge (§1.28)" do
|
||||
test "no badge in the default all-outstanding scope", ctx do
|
||||
{:ok, view, _} = live(ctx.conn, "/members")
|
||||
refute has_element?(view, "[data-testid='payment-period-badge']")
|
||||
end
|
||||
|
||||
test "a full year renders the compact year short code", ctx do
|
||||
{:ok, view, _} = live(ctx.conn, "/members?pay_from=2026-01-01&pay_to=2026-12-31")
|
||||
|
||||
assert has_element?(view, "[data-testid='payment-period-badge']", "2026")
|
||||
end
|
||||
|
||||
test "an arbitrary period falls back to a 'filtered' badge with a period tooltip", ctx do
|
||||
{:ok, view, _} = live(ctx.conn, "/members?pay_from=2026-02-03&pay_to=2026-08-17")
|
||||
|
||||
badge = view |> element("[data-testid='payment-period-badge']") |> render()
|
||||
assert badge =~ "03.02.2026"
|
||||
assert badge =~ "17.08.2026"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
40
test/mv_web/member_live/index_status_params_codec_test.exs
Normal file
40
test/mv_web/member_live/index_status_params_codec_test.exs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
defmodule MvWeb.MemberLive.IndexStatusParamsCodecTest do
|
||||
@moduledoc """
|
||||
§2.1 (extended) — URL codec round-trip for the new v2 filter keys: the
|
||||
point-in-time Stichtag date and the suspended-status flag. Encoding a state to
|
||||
params then decoding yields the canonical state, and the default (no filter)
|
||||
encodes to the empty map so a fresh URL is canonical.
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias MvWeb.MemberLive.Index.PaymentAging
|
||||
alias MvWeb.MemberLive.Index.Stichtag
|
||||
|
||||
describe "Stichtag date" do
|
||||
test "a set date round-trips" do
|
||||
date = ~D[2026-03-15]
|
||||
assert date |> Stichtag.to_params() |> Stichtag.parse() == date
|
||||
end
|
||||
|
||||
test "nil encodes to the empty map and decodes to nil" do
|
||||
assert Stichtag.to_params(nil) == %{}
|
||||
assert Stichtag.parse(%{}) == nil
|
||||
end
|
||||
|
||||
test "a malformed date param decodes to nil" do
|
||||
assert Stichtag.parse(%{"stichtag" => "not-a-date"}) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "suspended flag" do
|
||||
test "true round-trips" do
|
||||
assert true |> PaymentAging.suspended_to_params() |> PaymentAging.parse_suspended() == true
|
||||
end
|
||||
|
||||
test "false/nil encodes to the empty map and decodes to false" do
|
||||
assert PaymentAging.suspended_to_params(false) == %{}
|
||||
assert PaymentAging.suspended_to_params(nil) == %{}
|
||||
assert PaymentAging.parse_suspended(%{}) == false
|
||||
end
|
||||
end
|
||||
end
|
||||
78
test/mv_web/member_live/index_stichtag_property_test.exs
Normal file
78
test/mv_web/member_live/index_stichtag_property_test.exs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
defmodule MvWeb.MemberLive.IndexStichtagPropertyTest do
|
||||
@moduledoc """
|
||||
§2.6 — Point-in-time membership ("member on the reference date X").
|
||||
|
||||
For arbitrary (join_date, exit_date) pairs and reference dates X, the Stichtag
|
||||
filter keeps a member iff `join_date <= X and (exit_date is nil or exit_date > X)`.
|
||||
Verified at the `OverviewQuery` layer against an in-memory oracle.
|
||||
"""
|
||||
use Mv.DataCase, async: false
|
||||
use ExUnitProperties
|
||||
|
||||
import Mv.Fixtures, only: [member_fixture_with_actor: 2]
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias MvWeb.MemberLive.Index.OverviewQuery
|
||||
|
||||
@dates [
|
||||
~D[2023-01-01],
|
||||
~D[2024-06-15],
|
||||
~D[2025-01-01],
|
||||
~D[2025-06-15],
|
||||
~D[2026-01-01]
|
||||
]
|
||||
|
||||
defp clear(actor) do
|
||||
Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor))
|
||||
end
|
||||
|
||||
defp on_date?(join, exit_date, x) do
|
||||
Date.compare(join, x) != :gt and (is_nil(exit_date) or Date.compare(exit_date, x) == :gt)
|
||||
end
|
||||
|
||||
# Generates a (join_date, exit_date) pair where exit_date is nil or on/after
|
||||
# join_date, so it satisfies the Member "exit not before join" validation.
|
||||
defp valid_pair_gen do
|
||||
StreamData.bind(StreamData.member_of(@dates), fn join ->
|
||||
later = [nil | Enum.filter(@dates, &(Date.compare(&1, join) == :gt))]
|
||||
|
||||
StreamData.bind(StreamData.member_of(later), fn exit_date ->
|
||||
StreamData.constant({join, exit_date})
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
property "Stichtag filter keeps exactly the members active on X" do
|
||||
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
check all(
|
||||
specs <- StreamData.list_of(valid_pair_gen(), min_length: 1, max_length: 5),
|
||||
x <- StreamData.member_of(@dates),
|
||||
max_runs: 25
|
||||
) do
|
||||
clear(actor)
|
||||
|
||||
created =
|
||||
Enum.map(specs, fn {join, exit_date} ->
|
||||
member =
|
||||
member_fixture_with_actor(%{join_date: join, exit_date: exit_date}, actor)
|
||||
|
||||
{member.id, join, exit_date}
|
||||
end)
|
||||
|
||||
expected =
|
||||
for {id, join, exit_date} <- created,
|
||||
on_date?(join, exit_date, x),
|
||||
into: MapSet.new(),
|
||||
do: id
|
||||
|
||||
actual =
|
||||
%{stichtag: x}
|
||||
|> OverviewQuery.build()
|
||||
|> Ash.read!(actor: actor)
|
||||
|> MapSet.new(& &1.id)
|
||||
|
||||
assert MapSet.equal?(actual, expected)
|
||||
end
|
||||
end
|
||||
end
|
||||
58
test/mv_web/member_live/index_suspended_filter_test.exs
Normal file
58
test/mv_web/member_live/index_suspended_filter_test.exs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
defmodule MvWeb.MemberLive.IndexSuspendedFilterTest do
|
||||
@moduledoc """
|
||||
§1.23 — Filter by `suspended` status.
|
||||
|
||||
With the suspended filter active, the overview keeps members that have at
|
||||
least one `suspended` cycle whose `cycle_end` falls in the active period.
|
||||
Suspended is filterable as its own status, independent of the unpaid count
|
||||
(which excludes suspended, §2.5). Verified at the `OverviewQuery` layer.
|
||||
"""
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
import Mv.Fixtures, only: [member_fixture_with_actor: 2, create_fee_type: 2, create_cycle: 4]
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias MvWeb.MemberLive.Index.OverviewQuery
|
||||
|
||||
setup do
|
||||
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor))
|
||||
ft = create_fee_type(%{interval: :yearly}, actor)
|
||||
%{actor: actor, ft: ft}
|
||||
end
|
||||
|
||||
defp member_with_cycle(ctx, status, cycle_start) do
|
||||
m = member_fixture_with_actor(%{}, ctx.actor)
|
||||
create_cycle(m, ctx.ft, %{cycle_start: cycle_start, status: status}, ctx.actor)
|
||||
m
|
||||
end
|
||||
|
||||
defp ids(opts, actor) do
|
||||
opts |> OverviewQuery.build() |> Ash.read!(actor: actor) |> MapSet.new(& &1.id)
|
||||
end
|
||||
|
||||
test "keeps members with a suspended cycle whose cycle_end is in the period", ctx do
|
||||
suspended_in = member_with_cycle(ctx, :suspended, ~D[2025-01-01])
|
||||
_unpaid = member_with_cycle(ctx, :unpaid, ~D[2025-01-01])
|
||||
_paid = member_with_cycle(ctx, :paid, ~D[2025-01-01])
|
||||
_suspended_out = member_with_cycle(ctx, :suspended, ~D[2023-01-01])
|
||||
|
||||
result =
|
||||
ids(
|
||||
%{suspended: true, payment_period: %{from: ~D[2025-01-01], to: ~D[2025-12-31]}},
|
||||
ctx.actor
|
||||
)
|
||||
|
||||
assert MapSet.equal?(result, MapSet.new([suspended_in.id]))
|
||||
end
|
||||
|
||||
test "all-time period keeps every member with any suspended cycle", ctx do
|
||||
s1 = member_with_cycle(ctx, :suspended, ~D[2025-01-01])
|
||||
s2 = member_with_cycle(ctx, :suspended, ~D[2023-01-01])
|
||||
_unpaid = member_with_cycle(ctx, :unpaid, ~D[2025-01-01])
|
||||
|
||||
result = ids(%{suspended: true, payment_period: %{from: nil, to: nil}}, ctx.actor)
|
||||
|
||||
assert MapSet.equal?(result, MapSet.new([s1.id, s2.id]))
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue