86 lines
2.6 KiB
Elixir
86 lines
2.6 KiB
Elixir
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
|