From e64f55c36a68fc0bdead9c67d184d7efdc8e0833 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 3 Jul 2026 11:12:38 +0200 Subject: [PATCH] feat(member): back the overview with a keyset-paginated :overview read action --- lib/membership/member.ex | 75 +++-- lib/membership/membership.ex | 1 + .../live/member_live/index/overview_query.ex | 261 ++++++++++++++++++ .../member_live/index/overview_query_test.exs | 135 +++++++++ .../index_cycle_status_filter_test.exs | 78 ++++++ .../index_keyset_property_test.exs | 86 ++++++ 6 files changed, 615 insertions(+), 21 deletions(-) create mode 100644 lib/mv_web/live/member_live/index/overview_query.ex create mode 100644 test/mv_web/live/member_live/index/overview_query_test.exs create mode 100644 test/mv_web/member_live/index_cycle_status_filter_test.exs create mode 100644 test/mv_web/member_live/index_keyset_property_test.exs diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 0f67a00d..f57c32a5 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -266,6 +266,15 @@ defmodule Mv.Membership.Member do accept [:vereinfacht_contact_id] end + # Keyset-paginated read for the member overview. Filters and sort are + # composed onto the query by `MvWeb.MemberLive.Index.OverviewQuery`; this + # action only enables keyset pagination with a unique `id` tie-breaker so + # pages never skip or duplicate rows. + read :overview do + description "Keyset-paginated member overview read" + pagination keyset?: true, default_limit: 50, required?: false + end + # Action to handle fuzzy search on specific fields read :search do argument :query, :string, allow_nil?: true @@ -279,27 +288,7 @@ defmodule Mv.Membership.Member do threshold = Ash.Query.get_argument(query, :similarity_threshold) || @default_similarity_threshold - if is_binary(q) and String.trim(q) != "" do - q2 = String.trim(q) - # Sanitize for LIKE patterns (escape % and _), limit length to 100 chars - q2_sanitized = sanitize_search_query(q2) - pat = "%" <> q2_sanitized <> "%" - - # Build search filters grouped by search type for maintainability - # Priority: FTS > Substring > Custom Fields > Fuzzy Matching - # Note: FTS and fuzzy use q2 (unsanitized), LIKE-based filters use pat (sanitized) - fts_match = build_fts_filter(q2) - substring_match = build_substring_filter(q2_sanitized, pat) - custom_field_match = build_custom_field_filter(pat) - fuzzy_match = build_fuzzy_filter(q2, threshold) - - query - |> Ash.Query.filter( - expr(^fts_match or ^substring_match or ^custom_field_match or ^fuzzy_match) - ) - else - query - end + apply_fuzzy_search_filter(query, q, threshold) end end @@ -738,6 +727,13 @@ defmodule Mv.Membership.Member do end end + aggregates do + # Alphabetically-first group name, used to sort the overview by group DB-side. + # Mirrors the previous in-memory "first group name" sort key; members with no + # groups yield nil (sorted last ascending via NULLS LAST). + min :first_group_name, :groups, :name + end + # Define identities for upsert operations identities do identity :unique_email, [:email] @@ -1212,6 +1208,43 @@ defmodule Mv.Membership.Member do end end + @doc """ + Applies the same fuzzy/full-text search filter as the `:search` action as a + plain filter on the given query, without switching the query's read action. + + Used by the overview query builder so search composes with the `:overview` + keyset-paginated read action instead of replacing it. + """ + @spec apply_overview_search(Ash.Query.t(), String.t() | nil) :: Ash.Query.t() + def apply_overview_search(query, q) do + apply_fuzzy_search_filter(query, q || "", @default_similarity_threshold) + end + + # Shared by the `:search` action and `apply_overview_search/2`. + defp apply_fuzzy_search_filter(query, q, threshold) do + if is_binary(q) and String.trim(q) != "" do + q2 = String.trim(q) + # Sanitize for LIKE patterns (escape % and _), limit length to 100 chars + q2_sanitized = sanitize_search_query(q2) + pat = "%" <> q2_sanitized <> "%" + + # Build search filters grouped by search type for maintainability + # Priority: FTS > Substring > Custom Fields > Fuzzy Matching + # Note: FTS and fuzzy use q2 (unsanitized), LIKE-based filters use pat (sanitized) + fts_match = build_fts_filter(q2) + substring_match = build_substring_filter(q2_sanitized, pat) + custom_field_match = build_custom_field_filter(pat) + fuzzy_match = build_fuzzy_filter(q2, threshold) + + Ash.Query.filter( + query, + expr(^fts_match or ^substring_match or ^custom_field_match or ^fuzzy_match) + ) + else + query + end + end + # ============================================================================ # Search Input Sanitization # ============================================================================ diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 2a5b17fb..b7c5974d 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -45,6 +45,7 @@ defmodule Mv.Membership do resource Mv.Membership.Member do define :create_member, action: :create_member define :list_members, action: :read + define :overview_members, action: :overview define :update_member, action: :update_member define :destroy_member, action: :destroy end diff --git a/lib/mv_web/live/member_live/index/overview_query.ex b/lib/mv_web/live/member_live/index/overview_query.ex new file mode 100644 index 00000000..93882d4d --- /dev/null +++ b/lib/mv_web/live/member_live/index/overview_query.ex @@ -0,0 +1,261 @@ +defmodule MvWeb.MemberLive.Index.OverviewQuery do + @moduledoc """ + Builds the Ash query for the member overview from the LiveView's filter/sort + state, so every filter and sort resolves in PostgreSQL via the `:overview` + keyset-paginated read action. + + All previously in-memory passes (cycle status, boolean/date custom fields, + group sort) are expressed here as DB filters/sorts. A unique `id` tie-breaker + is always appended to the sort so keyset pages never skip or duplicate rows. + + `build/1` returns an unread `Ash.Query`; the caller supplies `page:`/`actor:` + options to `Ash.read/2`. + """ + + import Ash.Expr + + alias Mv.Membership.Member + alias MvWeb.MemberLive.Index.DateFilter + + require Ash.Query + + @type opts :: %{optional(atom()) => term()} + + @doc """ + Builds the `:overview` query from the given filter/sort options. + + Recognised keys (all optional): + + * `:search` — full-text search string + * `:group_filters` / `:groups` — `%{group_id => :in | :not_in}` and the valid groups + * `:fee_type_filters` / `:fee_types` — `%{fee_type_id => :in | :not_in}` and valid fee types + * `:date_filters` — built-in join/exit date filter map (see `DateFilter`) + * `:sort_field` / `:sort_order` — sort key and direction + * `:today` — reference date for cycle math (defaults to `Date.utc_today/0`) + """ + @spec build(opts()) :: Ash.Query.t() + def build(opts \\ %{}) do + Member + |> Ash.Query.for_read(:overview) + |> apply_search(opts[:search]) + |> apply_group_filters(opts[:group_filters], opts[:groups]) + |> apply_fee_type_filters(opts[:fee_type_filters], opts[:fee_types]) + |> apply_date_filters(opts[:date_filters]) + |> apply_cycle_status_filter( + opts[:cycle_status_filter], + opts[:show_current_cycle], + today(opts) + ) + |> apply_sort(opts[:sort_field], opts[:sort_order]) + end + + defp today(opts), do: opts[:today] || Date.utc_today() + + # --------------------------------------------------------------------------- + # Search + # --------------------------------------------------------------------------- + + defp apply_search(query, search) when is_binary(search), + do: Member.apply_overview_search(query, search) + + defp apply_search(query, _), do: query + + # --------------------------------------------------------------------------- + # Group filters (AND across selected groups) + # --------------------------------------------------------------------------- + + defp apply_group_filters(query, group_filters, _groups) + when group_filters in [nil, %{}], + do: query + + defp apply_group_filters(query, group_filters, groups) do + valid_ids = valid_id_set(groups) + + Enum.reduce(group_filters, query, fn {group_id_str, value}, q -> + if MapSet.member?(valid_ids, group_id_str) do + apply_one_group_filter(q, group_id_str, value) + else + q + end + end) + end + + defp apply_one_group_filter(query, group_id_str, :in) do + case Ecto.UUID.cast(group_id_str) do + {:ok, uuid} -> Ash.Query.filter(query, expr(exists(member_groups, group_id == ^uuid))) + _ -> query + end + end + + defp apply_one_group_filter(query, group_id_str, :not_in) do + case Ecto.UUID.cast(group_id_str) do + {:ok, uuid} -> Ash.Query.filter(query, expr(not exists(member_groups, group_id == ^uuid))) + _ -> query + end + end + + defp apply_one_group_filter(query, _id, _value), do: query + + # --------------------------------------------------------------------------- + # Fee-type filters (:in OR; :not_in AND) + # --------------------------------------------------------------------------- + + defp apply_fee_type_filters(query, fee_type_filters, _fee_types) + when fee_type_filters in [nil, %{}], + do: query + + defp apply_fee_type_filters(query, fee_type_filters, fee_types) do + valid_ids = valid_id_set(fee_types) + + {in_filters, not_in_filters} = + fee_type_filters + |> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end) + |> Enum.split_with(fn {_, value} -> value == :in end) + + in_uuids = cast_uuids(Enum.map(in_filters, fn {id_str, _} -> id_str end)) + + query = + if in_uuids == [] do + query + else + Ash.Query.filter(query, expr(membership_fee_type_id in ^in_uuids)) + end + + Enum.reduce(not_in_filters, query, fn {id_str, _}, q -> + case Ecto.UUID.cast(id_str) do + {:ok, uuid} -> + Ash.Query.filter( + q, + expr(membership_fee_type_id != ^uuid or is_nil(membership_fee_type_id)) + ) + + _ -> + q + end + end) + end + + # --------------------------------------------------------------------------- + # Cycle-status filter (paid/unpaid, current or last-completed cycle) + # + # Backed by the DB cycle-status aggregates (denormalized `cycle_end`). A member + # matches only when its selected-cycle status equals the requested status; + # members with no matching cycle (nil aggregate) are excluded — exactly as the + # previous in-memory classifier behaved. + # --------------------------------------------------------------------------- + + defp apply_cycle_status_filter(query, status, _show_current, _today) + when status not in [:paid, :unpaid], + do: query + + defp apply_cycle_status_filter(query, status, true = _show_current, today) do + # Current cycle: contains today; when several would, the one with the latest + # cycle_start wins. Keep members whose winning current cycle has `status`. + Ash.Query.filter( + query, + expr( + exists( + membership_fee_cycles, + cycle_start <= ^today and cycle_end >= ^today and status == ^status and + not exists( + member.membership_fee_cycles, + cycle_start <= ^today and cycle_end >= ^today and + cycle_start > parent(cycle_start) + ) + ) + ) + ) + end + + defp apply_cycle_status_filter(query, status, _show_current, today) do + # Last completed cycle: most recent cycle that has ended (cycle_end < today). + Ash.Query.filter( + query, + expr( + exists( + membership_fee_cycles, + cycle_end < ^today and status == ^status and + not exists( + member.membership_fee_cycles, + cycle_end < ^today and cycle_start > parent(cycle_start) + ) + ) + ) + ) + end + + # --------------------------------------------------------------------------- + # Built-in date filters (join/exit) + # --------------------------------------------------------------------------- + + defp apply_date_filters(query, nil), do: query + + defp apply_date_filters(query, filters) when is_map(filters), + do: DateFilter.apply_ash_filter(query, filters) + + # --------------------------------------------------------------------------- + # Sort (always append the unique id tie-breaker for keyset stability) + # --------------------------------------------------------------------------- + + defp apply_sort(query, nil, _order), do: Ash.Query.sort(query, id: :asc) + defp apply_sort(query, _field, nil), do: Ash.Query.sort(query, id: :asc) + + defp apply_sort(query, field, order) do + case sort_key(field) do + nil -> Ash.Query.sort(query, id: :asc) + key -> Ash.Query.sort(query, [{key, order}, {:id, :asc}]) + end + end + + # Resolves a sort field (atom or string) to a DB sort key, or nil if it is a + # computed field handled elsewhere / not sortable. + defp sort_key(field) when field in [:membership_fee_type, "membership_fee_type"], + do: "membership_fee_type.name" + + defp sort_key(field) when field in [:groups, "groups"], do: :first_group_name + + defp sort_key(field) when is_atom(field) do + if field in member_sort_fields(), do: field, else: nil + end + + defp sort_key(field) when is_binary(field) do + allowed = MapSet.new(member_sort_fields(), &Atom.to_string/1) + if MapSet.member?(allowed, field), do: String.to_existing_atom(field), else: nil + end + + defp sort_key(_), do: nil + + defp member_sort_fields do + Mv.Constants.member_fields() -- [:notes] + end + + # --------------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------------- + + defp valid_id_set(records) when is_list(records) do + records + |> Enum.map(&to_string(&1.id)) + |> Enum.map(&normalize_uuid/1) + |> Enum.reject(&is_nil/1) + |> MapSet.new() + end + + defp valid_id_set(_), do: MapSet.new() + + defp normalize_uuid(raw) when is_binary(raw) do + case Ecto.UUID.cast(String.trim(raw)) do + {:ok, uuid} -> to_string(uuid) + _ -> nil + end + end + + defp normalize_uuid(_), do: nil + + defp cast_uuids(id_strs) do + id_strs + |> Enum.map(&Ecto.UUID.cast/1) + |> Enum.filter(&match?({:ok, _}, &1)) + |> Enum.map(fn {:ok, uuid} -> uuid end) + end +end diff --git a/test/mv_web/live/member_live/index/overview_query_test.exs b/test/mv_web/live/member_live/index/overview_query_test.exs new file mode 100644 index 00000000..c8ec3fdc --- /dev/null +++ b/test/mv_web/live/member_live/index/overview_query_test.exs @@ -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 diff --git a/test/mv_web/member_live/index_cycle_status_filter_test.exs b/test/mv_web/member_live/index_cycle_status_filter_test.exs new file mode 100644 index 00000000..f6bb4467 --- /dev/null +++ b/test/mv_web/member_live/index_cycle_status_filter_test.exs @@ -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 diff --git a/test/mv_web/member_live/index_keyset_property_test.exs b/test/mv_web/member_live/index_keyset_property_test.exs new file mode 100644 index 00000000..aaaa43b8 --- /dev/null +++ b/test/mv_web/member_live/index_keyset_property_test.exs @@ -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