feat(member): back the overview with a keyset-paginated :overview read action
This commit is contained in:
parent
b745b13ca5
commit
e64f55c36a
6 changed files with 615 additions and 21 deletions
135
test/mv_web/live/member_live/index/overview_query_test.exs
Normal file
135
test/mv_web/live/member_live/index/overview_query_test.exs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
defmodule MvWeb.MemberLive.Index.OverviewQueryTest do
|
||||
@moduledoc """
|
||||
Resource-level tests for the DB-backed overview query builder: keyset
|
||||
pagination and the standard DB filters/sort pushed down from the LiveView.
|
||||
"""
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
import Mv.Fixtures,
|
||||
only: [member_fixture_with_actor: 2, create_fee_type: 2, group_fixture: 1]
|
||||
|
||||
alias Mv.Membership
|
||||
alias MvWeb.MemberLive.Index.OverviewQuery
|
||||
|
||||
setup do
|
||||
%{actor: Mv.Helpers.SystemActor.get_system_actor()}
|
||||
end
|
||||
|
||||
defp ids(results), do: Enum.map(results, & &1.id) |> MapSet.new()
|
||||
|
||||
defp read_all_pages(query, actor, limit) do
|
||||
page = Ash.read!(query, page: [limit: limit], actor: actor)
|
||||
collect_pages(page, [page.results])
|
||||
end
|
||||
|
||||
defp collect_pages(%{more?: true} = page, acc) do
|
||||
next = Ash.page!(page, :next)
|
||||
collect_pages(next, [next.results | acc])
|
||||
end
|
||||
|
||||
defp collect_pages(_page, acc), do: acc |> Enum.reverse() |> List.flatten()
|
||||
|
||||
describe "keyset pagination" do
|
||||
test "concatenated pages cover the full set once with no duplicates", %{actor: actor} do
|
||||
members =
|
||||
for _ <- 1..5, do: member_fixture_with_actor(%{}, actor)
|
||||
|
||||
expected = ids(members)
|
||||
|
||||
query = OverviewQuery.build(%{sort_field: :inserted_at, sort_order: :asc})
|
||||
all = read_all_pages(query, actor, 2)
|
||||
|
||||
assert ids(all) == expected
|
||||
assert length(all) == length(Enum.uniq_by(all, & &1.id))
|
||||
end
|
||||
end
|
||||
|
||||
describe "DB filters" do
|
||||
test "search matches name DB-side", %{actor: actor} do
|
||||
hit = member_fixture_with_actor(%{first_name: "Zorblax", last_name: "Quux"}, actor)
|
||||
_miss = member_fixture_with_actor(%{first_name: "Otto", last_name: "Normal"}, actor)
|
||||
|
||||
results =
|
||||
OverviewQuery.build(%{search: "Zorblax"})
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
assert hit.id in Enum.map(results, & &1.id)
|
||||
refute Enum.any?(results, &(&1.first_name == "Otto"))
|
||||
end
|
||||
|
||||
test "group :in and :not_in resolve DB-side", %{actor: actor} do
|
||||
group = group_fixture(%{name: "Board #{System.unique_integer([:positive])}"})
|
||||
in_member = member_fixture_with_actor(%{}, actor)
|
||||
out_member = member_fixture_with_actor(%{}, actor)
|
||||
|
||||
{:ok, _} =
|
||||
Membership.create_member_group(%{member_id: in_member.id, group_id: group.id},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
opts = %{group_filters: %{to_string(group.id) => :in}, groups: [group]}
|
||||
in_ids = OverviewQuery.build(opts) |> Ash.read!(actor: actor) |> Enum.map(& &1.id)
|
||||
assert in_member.id in in_ids
|
||||
refute out_member.id in in_ids
|
||||
|
||||
not_opts = %{group_filters: %{to_string(group.id) => :not_in}, groups: [group]}
|
||||
out_ids = OverviewQuery.build(not_opts) |> Ash.read!(actor: actor) |> Enum.map(& &1.id)
|
||||
refute in_member.id in out_ids
|
||||
assert out_member.id in out_ids
|
||||
end
|
||||
|
||||
test "fee-type :in resolves DB-side", %{actor: actor} do
|
||||
ft = create_fee_type(%{}, actor)
|
||||
with_ft = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor)
|
||||
without_ft = member_fixture_with_actor(%{}, actor)
|
||||
|
||||
opts = %{fee_type_filters: %{to_string(ft.id) => :in}, fee_types: [ft]}
|
||||
result_ids = OverviewQuery.build(opts) |> Ash.read!(actor: actor) |> Enum.map(& &1.id)
|
||||
|
||||
assert with_ft.id in result_ids
|
||||
refute without_ft.id in result_ids
|
||||
end
|
||||
end
|
||||
|
||||
describe "group sort (DB aggregate)" do
|
||||
test "sorts by first group name DB-side, members with groups before those without", %{
|
||||
actor: actor
|
||||
} do
|
||||
g_a = group_fixture(%{name: "AAA #{System.unique_integer([:positive])}"})
|
||||
g_z = group_fixture(%{name: "ZZZ #{System.unique_integer([:positive])}"})
|
||||
|
||||
m_a = member_fixture_with_actor(%{}, actor)
|
||||
m_z = member_fixture_with_actor(%{}, actor)
|
||||
m_none = member_fixture_with_actor(%{}, actor)
|
||||
|
||||
{:ok, _} =
|
||||
Membership.create_member_group(%{member_id: m_a.id, group_id: g_a.id}, actor: actor)
|
||||
|
||||
{:ok, _} =
|
||||
Membership.create_member_group(%{member_id: m_z.id, group_id: g_z.id}, actor: actor)
|
||||
|
||||
asc =
|
||||
OverviewQuery.build(%{sort_field: :groups, sort_order: :asc})
|
||||
|> Ash.read!(actor: actor)
|
||||
|> Enum.map(& &1.id)
|
||||
|> Enum.filter(&(&1 in [m_a.id, m_z.id, m_none.id]))
|
||||
|
||||
# group members first (AAA before ZZZ), member without groups last
|
||||
assert asc == [m_a.id, m_z.id, m_none.id]
|
||||
end
|
||||
end
|
||||
|
||||
describe "sort" do
|
||||
test "sort by last_name is deterministic with id tie-breaker", %{actor: actor} do
|
||||
a = member_fixture_with_actor(%{last_name: "Same"}, actor)
|
||||
b = member_fixture_with_actor(%{last_name: "Same"}, actor)
|
||||
|
||||
results =
|
||||
OverviewQuery.build(%{sort_field: :last_name, sort_order: :asc})
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
ordered = results |> Enum.filter(&(&1.id in [a.id, b.id])) |> Enum.map(& &1.id)
|
||||
assert ordered == Enum.sort([a.id, b.id])
|
||||
end
|
||||
end
|
||||
end
|
||||
78
test/mv_web/member_live/index_cycle_status_filter_test.exs
Normal file
78
test/mv_web/member_live/index_cycle_status_filter_test.exs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
defmodule MvWeb.MemberLive.IndexCycleStatusFilterTest do
|
||||
@moduledoc """
|
||||
§1.13 — the paid/unpaid cycle-status filter, under the current or last-completed
|
||||
cycle view, returns the same members as the previous in-memory classifier,
|
||||
computed DB-side via the `cycle_end`-backed aggregates.
|
||||
"""
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4]
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||
alias MvWeb.MemberLive.Index.OverviewQuery
|
||||
|
||||
@today ~D[2024-07-15]
|
||||
|
||||
setup do
|
||||
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
ft = create_fee_type(%{interval: :yearly}, actor)
|
||||
|
||||
# current 2024 paid, last-completed 2023 unpaid
|
||||
paid_now = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor)
|
||||
create_cycle(paid_now, ft, %{cycle_start: ~D[2024-01-01], status: :paid}, actor)
|
||||
create_cycle(paid_now, ft, %{cycle_start: ~D[2023-01-01], status: :unpaid}, actor)
|
||||
|
||||
# current 2024 unpaid, last-completed 2023 paid
|
||||
unpaid_now = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor)
|
||||
create_cycle(unpaid_now, ft, %{cycle_start: ~D[2024-01-01], status: :unpaid}, actor)
|
||||
create_cycle(unpaid_now, ft, %{cycle_start: ~D[2023-01-01], status: :paid}, actor)
|
||||
|
||||
# no cycles
|
||||
_no_cycles = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor)
|
||||
|
||||
%{actor: actor, paid_now: paid_now, unpaid_now: unpaid_now}
|
||||
end
|
||||
|
||||
defp db_ids(status, show_current, actor) do
|
||||
OverviewQuery.build(%{
|
||||
cycle_status_filter: status,
|
||||
show_current_cycle: show_current,
|
||||
today: @today
|
||||
})
|
||||
|> Ash.read!(actor: actor)
|
||||
|> MapSet.new(& &1.id)
|
||||
end
|
||||
|
||||
defp oracle_ids(status, show_current, actor) do
|
||||
Member
|
||||
|> Ash.Query.load(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.Query.load(:membership_fee_type)
|
||||
|> Ash.read!(actor: actor)
|
||||
|> Enum.filter(fn m ->
|
||||
MembershipFeeStatus.get_cycle_status_for_member(m, show_current, @today) == status
|
||||
end)
|
||||
|> MapSet.new(& &1.id)
|
||||
end
|
||||
|
||||
# {status, show_current, member-key expected in the result}
|
||||
@cases [
|
||||
{:paid, true, :paid_now},
|
||||
{:unpaid, true, :unpaid_now},
|
||||
{:paid, false, :unpaid_now},
|
||||
{:unpaid, false, :paid_now}
|
||||
]
|
||||
|
||||
for {status, show_current, expected_key} <- @cases do
|
||||
test "#{status} under #{if show_current, do: "current", else: "last"} cycle matches oracle",
|
||||
%{actor: actor} = ctx do
|
||||
status = unquote(status)
|
||||
show_current = unquote(show_current)
|
||||
expected = Map.fetch!(ctx, unquote(expected_key))
|
||||
|
||||
db = db_ids(status, show_current, actor)
|
||||
assert db == oracle_ids(status, show_current, actor)
|
||||
assert expected.id in db
|
||||
end
|
||||
end
|
||||
end
|
||||
86
test/mv_web/member_live/index_keyset_property_test.exs
Normal file
86
test/mv_web/member_live/index_keyset_property_test.exs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
defmodule MvWeb.MemberLive.IndexKeysetPropertyTest do
|
||||
@moduledoc """
|
||||
§2.2 — Keyset pagination completeness: concatenating every keyset page yields
|
||||
exactly the full filtered set once each (no duplicates, no gaps) in a stable
|
||||
total order.
|
||||
|
||||
§2.3 — Sort determinism with the unique `id` tie-breaker: members equal on the
|
||||
sort key are ordered by `id`, and the result set equals the unsorted set.
|
||||
"""
|
||||
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
|
||||
|
||||
setup do
|
||||
%{actor: Mv.Helpers.SystemActor.get_system_actor()}
|
||||
end
|
||||
|
||||
defp clear_members(actor) do
|
||||
Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor))
|
||||
end
|
||||
|
||||
defp read_all_pages(query, actor, limit) do
|
||||
page = Ash.read!(query, page: [limit: limit], actor: actor)
|
||||
collect(page, page.results)
|
||||
end
|
||||
|
||||
defp collect(%{more?: true} = page, acc) do
|
||||
next = Ash.page!(page, :next)
|
||||
collect(next, acc ++ next.results)
|
||||
end
|
||||
|
||||
defp collect(_page, acc), do: acc
|
||||
|
||||
property "keyset pages cover the full set once, no dupes or gaps", %{actor: actor} do
|
||||
check all(
|
||||
n <- integer(1..12),
|
||||
limit <- integer(1..5),
|
||||
sort <- member_of([:last_name, :join_date, :inserted_at]),
|
||||
order <- member_of([:asc, :desc]),
|
||||
max_runs: 25
|
||||
) do
|
||||
clear_members(actor)
|
||||
# Deliberate ties on last_name so the tie-breaker is exercised.
|
||||
members =
|
||||
for i <- 1..n do
|
||||
member_fixture_with_actor(%{last_name: "Tie#{rem(i, 3)}"}, actor)
|
||||
end
|
||||
|
||||
expected = MapSet.new(members, & &1.id)
|
||||
|
||||
query = OverviewQuery.build(%{sort_field: sort, sort_order: order})
|
||||
paged = read_all_pages(query, actor, limit)
|
||||
paged_ids = Enum.map(paged, & &1.id)
|
||||
|
||||
assert MapSet.new(paged_ids) == expected
|
||||
assert length(paged_ids) == MapSet.size(expected)
|
||||
end
|
||||
end
|
||||
|
||||
property "sort with ties is a deterministic total order broken by id", %{actor: actor} do
|
||||
check all(
|
||||
n <- integer(2..10),
|
||||
order <- member_of([:asc, :desc]),
|
||||
max_runs: 25
|
||||
) do
|
||||
clear_members(actor)
|
||||
|
||||
members =
|
||||
for _ <- 1..n do
|
||||
member_fixture_with_actor(%{last_name: "Same"}, actor)
|
||||
end
|
||||
|
||||
ordered =
|
||||
OverviewQuery.build(%{sort_field: :last_name, sort_order: order})
|
||||
|> Ash.read!(actor: actor)
|
||||
|> Enum.map(& &1.id)
|
||||
|
||||
# All last_names equal -> order is fully determined by the id tie-breaker (asc).
|
||||
assert ordered == Enum.sort(Enum.map(members, & &1.id))
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue