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