From ddd4a9a878f7f061ebe9e63706ca750764e94bed Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 20 May 2026 16:24:08 +0200 Subject: [PATCH] feat(date-filter): introduce DateFilter module with URL codec and Ash query expressions --- lib/mv/constants.ex | 76 ++++ .../live/member_live/index/date_filter.ex | 402 ++++++++++++++++++ test/mv/constants_test.exs | 33 ++ .../date_filter_custom_field_test.exs | 210 +++++++++ .../member_live/date_filter_default_test.exs | 24 ++ .../member_live/date_filter_property_test.exs | 134 ++++++ .../live/member_live/date_filter_test.exs | 316 ++++++++++++++ 7 files changed, 1195 insertions(+) create mode 100644 lib/mv_web/live/member_live/index/date_filter.ex create mode 100644 test/mv/constants_test.exs create mode 100644 test/mv_web/live/member_live/date_filter_custom_field_test.exs create mode 100644 test/mv_web/live/member_live/date_filter_default_test.exs create mode 100644 test/mv_web/live/member_live/date_filter_property_test.exs create mode 100644 test/mv_web/live/member_live/date_filter_test.exs diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 517ad2f..4d09c89 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -26,6 +26,18 @@ defmodule Mv.Constants do @fee_type_filter_prefix "fee_type_" + @join_date_from_param "jd_from" + + @join_date_to_param "jd_to" + + @exit_date_mode_param "ed_mode" + + @exit_date_from_param "ed_from" + + @exit_date_to_param "ed_to" + + @custom_date_filter_prefix "cdf_" + @max_boolean_filters 50 @max_uuid_length 36 @@ -84,6 +96,70 @@ defmodule Mv.Constants do """ def fee_type_filter_prefix, do: @fee_type_filter_prefix + @doc """ + Returns the URL parameter name for the join_date lower bound filter. + + ## Examples + + iex> Mv.Constants.join_date_from_param() + "jd_from" + """ + def join_date_from_param, do: @join_date_from_param + + @doc """ + Returns the URL parameter name for the join_date upper bound filter. + + ## Examples + + iex> Mv.Constants.join_date_to_param() + "jd_to" + """ + def join_date_to_param, do: @join_date_to_param + + @doc """ + Returns the URL parameter name for the exit_date filter mode + (`active_only` | `inactive_only` | `all` | `custom`). + + ## Examples + + iex> Mv.Constants.exit_date_mode_param() + "ed_mode" + """ + def exit_date_mode_param, do: @exit_date_mode_param + + @doc """ + Returns the URL parameter name for the exit_date lower bound filter + (only relevant when ed_mode=custom). + + ## Examples + + iex> Mv.Constants.exit_date_from_param() + "ed_from" + """ + def exit_date_from_param, do: @exit_date_from_param + + @doc """ + Returns the URL parameter name for the exit_date upper bound filter + (only relevant when ed_mode=custom). + + ## Examples + + iex> Mv.Constants.exit_date_to_param() + "ed_to" + """ + def exit_date_to_param, do: @exit_date_to_param + + @doc """ + Returns the prefix for custom date field filter URL parameters + (e.g. cdf__from / cdf__to). + + ## Examples + + iex> Mv.Constants.custom_date_filter_prefix() + "cdf_" + """ + def custom_date_filter_prefix, do: @custom_date_filter_prefix + @doc """ Returns the maximum number of boolean custom field filters allowed per request. diff --git a/lib/mv_web/live/member_live/index/date_filter.ex b/lib/mv_web/live/member_live/index/date_filter.ex new file mode 100644 index 0000000..2a3e04e --- /dev/null +++ b/lib/mv_web/live/member_live/index/date_filter.ex @@ -0,0 +1,402 @@ +defmodule MvWeb.MemberLive.Index.DateFilter do + @moduledoc """ + Encapsulates the complete lifecycle of date-range filters used on the + member overview page. + + Owns: + + - the default filter state (active members only) + - URL encoding / decoding of filter state + - DB-level Ash expression construction for built-in date fields + (`join_date`, `exit_date`) + - in-memory predicates for custom date-typed custom fields + + ## Filter state shape + + %{ + join_date: %{from: nil | %Date{}, to: nil | %Date{}}, + exit_date: %{ + mode: :active_only | :inactive_only | :all | :custom, + from: nil | %Date{}, + to: nil | %Date{} + }, + # optional custom date field entries (UUID string keys): + "" => %{from: nil | %Date{}, to: nil | %Date{}} + } + + The default mode for `exit_date` is `:active_only`, which means + `exit_date IS NULL OR exit_date > today` — a member who left today is hidden. + """ + + require Ash.Query + import Ash.Expr + + @join_date_from_param Mv.Constants.join_date_from_param() + @join_date_to_param Mv.Constants.join_date_to_param() + @exit_date_mode_param Mv.Constants.exit_date_mode_param() + @exit_date_from_param Mv.Constants.exit_date_from_param() + @exit_date_to_param Mv.Constants.exit_date_to_param() + @custom_date_filter_prefix Mv.Constants.custom_date_filter_prefix() + + @doc """ + Returns the default date filter state used on fresh page load and after + "Clear filters". `exit_date` is set to `:active_only`; all other bounds are nil. + """ + @spec default() :: %{ + join_date: %{from: nil, to: nil}, + exit_date: %{mode: :active_only, from: nil, to: nil} + } + def default do + %{ + join_date: %{from: nil, to: nil}, + exit_date: %{mode: :active_only, from: nil, to: nil} + } + end + + @doc """ + Decodes URL params into a date filter state map. + + Recognized keys: + + * `"jd_from"` / `"jd_to"` — join_date bounds (ISO-8601 dates) + * `"ed_mode"` — exit_date mode (`"active_only"` | `"inactive_only"` | + `"all"` | `"custom"`); absent or unknown values fall back to + `:active_only` + * `"ed_from"` / `"ed_to"` — exit_date bounds (ISO-8601 dates, used when + `ed_mode=custom`) + * `"cdf__from"` / `"cdf__to"` — custom date field bounds; + the UUID must appear (by `to_string/1` on its `:id`) in + `date_custom_fields`, otherwise the entry is dropped + + Malformed ISO-8601 strings are silently discarded; the corresponding bound + stays `nil`. No exception is raised for any malformed input. + """ + @spec from_params(map(), list()) :: map() + def from_params(params, date_custom_fields) + when is_map(params) and is_list(date_custom_fields) do + base = %{ + join_date: %{ + from: parse_date(Map.get(params, @join_date_from_param)), + to: parse_date(Map.get(params, @join_date_to_param)) + }, + exit_date: %{ + mode: parse_exit_date_mode(Map.get(params, @exit_date_mode_param)), + from: parse_date(Map.get(params, @exit_date_from_param)), + to: parse_date(Map.get(params, @exit_date_to_param)) + } + } + + parse_custom_date_filters(params, date_custom_fields, base) + end + + @doc """ + Encodes a date filter state map into a URL params map (string keys, string + values). + + Encoding rules: + + * `join_date` from/to → `"jd_from"` / `"jd_to"` (omitted when nil) + * `exit_date` mode → + - `:active_only` is the default and is omitted entirely (no `ed_mode`, + no bounds — a fresh URL is the canonical representation of the default + state) + - `:all` / `:inactive_only` → `"ed_mode"` only; bounds are omitted + - `:custom` → `"ed_mode" => "custom"` plus `"ed_from"` / `"ed_to"` + when those bounds are set + * custom date field entries (UUID string keys) → `"cdf__from"` / + `"cdf__to"`; each bound is included only when non-nil; an entry + with both bounds nil produces no params + + All dates are serialized via `Date.to_iso8601/1`. + """ + @spec to_params(map()) :: %{optional(String.t()) => String.t()} + def to_params(filters) when is_map(filters) do + %{} + |> put_join_date_params(Map.get(filters, :join_date, %{})) + |> put_exit_date_params(Map.get(filters, :exit_date, %{})) + |> put_custom_date_params(filters) + end + + @doc """ + Applies the DB-level portion of the date filter — `join_date` and + `exit_date` constraints — to the given Ash query. + + Exit_date semantics by mode: + + * `:active_only` → `is_nil(exit_date) or exit_date > today` + * `:inactive_only` → `not is_nil(exit_date) and exit_date <= today` + * `:all` → no filter added for exit_date + * `:custom` → `not is_nil(exit_date)` plus the active bounds; if both + bounds are nil, no filter is added (the user picked "custom" but + entered nothing) + + Join_date is purely a range filter — nil join_date is always excluded when + any bound is set: + + * `from` set → `not is_nil(join_date) and join_date >= from` + * `to` set → `not is_nil(join_date) and join_date <= to` + * neither set → no filter + + Today's date is captured via `Date.utc_today/0`; callers needing a frozen + clock should wrap the call site, not this function. + """ + @spec apply_ash_filter(Ash.Query.t() | module(), map()) :: Ash.Query.t() + def apply_ash_filter(query_or_resource, filters) when is_map(filters) do + query_or_resource + |> Ash.Query.new() + |> apply_exit_date_filter(Map.get(filters, :exit_date, %{})) + |> apply_join_date_filter(Map.get(filters, :join_date, %{})) + end + + defp apply_exit_date_filter(query, %{mode: :all}), do: query + + defp apply_exit_date_filter(query, %{mode: :active_only}) do + today = Date.utc_today() + Ash.Query.filter(query, expr(is_nil(exit_date) or exit_date > ^today)) + end + + defp apply_exit_date_filter(query, %{mode: :inactive_only}) do + today = Date.utc_today() + Ash.Query.filter(query, expr(not is_nil(exit_date) and exit_date <= ^today)) + end + + defp apply_exit_date_filter(query, %{mode: :custom, from: nil, to: nil}), do: query + + defp apply_exit_date_filter(query, %{mode: :custom, from: from, to: nil}) do + Ash.Query.filter(query, expr(not is_nil(exit_date) and exit_date >= ^from)) + end + + defp apply_exit_date_filter(query, %{mode: :custom, from: nil, to: to}) do + Ash.Query.filter(query, expr(not is_nil(exit_date) and exit_date <= ^to)) + end + + defp apply_exit_date_filter(query, %{mode: :custom, from: from, to: to}) do + Ash.Query.filter( + query, + expr(not is_nil(exit_date) and exit_date >= ^from and exit_date <= ^to) + ) + end + + defp apply_exit_date_filter(query, _), do: query + + defp apply_join_date_filter(query, %{from: nil, to: nil}), do: query + + defp apply_join_date_filter(query, %{from: from, to: nil}) when not is_nil(from) do + Ash.Query.filter(query, expr(not is_nil(join_date) and join_date >= ^from)) + end + + defp apply_join_date_filter(query, %{from: nil, to: to}) when not is_nil(to) do + Ash.Query.filter(query, expr(not is_nil(join_date) and join_date <= ^to)) + end + + defp apply_join_date_filter(query, %{from: from, to: to}) + when not is_nil(from) and not is_nil(to) do + Ash.Query.filter( + query, + expr(not is_nil(join_date) and join_date >= ^from and join_date <= ^to) + ) + end + + defp apply_join_date_filter(query, _), do: query + + @doc """ + Applies the in-memory portion of the date filter — custom date fields + whose values live in JSONB-backed `custom_field_values`. + + Behavior: + + * Only entries whose UUID key matches a `date_custom_fields` entry + (by `to_string(field.id)` and `value_type == :date`) are considered. + * Entries with both bounds nil add no constraint. + * For an active entry, a member is kept iff its custom field value is + present AND the value (unwrapped from `%Ash.Union{type: :date}`) + satisfies `value >= from` (when from set) AND `value <= to` + (when to set). + * Members with `custom_field_values` nil, `%Ash.NotLoaded{}`, an empty + list, or no entry for the active field — are excluded. + * Non-date `Ash.Union` types are treated as "no value" and exclude the + member. + + Returns the filtered list of members (order preserved). + """ + @spec apply_in_memory([map()], map(), [map()]) :: [map()] + def apply_in_memory(members, filters, date_custom_fields) + when is_list(members) and is_map(filters) and is_list(date_custom_fields) do + active_filters = active_custom_date_filters(filters, date_custom_fields) + + if active_filters == [] do + members + else + Enum.filter(members, &matches_all_custom_dates?(&1, active_filters)) + end + end + + defp matches_all_custom_dates?(member, active_filters) do + Enum.all?(active_filters, fn {id, bounds} -> + member_matches_custom_date?(member, id, bounds) + end) + end + + defp active_custom_date_filters(filters, date_custom_fields) do + valid_ids = + date_custom_fields + |> Enum.filter(&date_field?/1) + |> MapSet.new(&to_string(field_id(&1))) + + filters + |> Enum.filter(fn + {key, %{from: from, to: to}} when is_binary(key) -> + MapSet.member?(valid_ids, key) and (not is_nil(from) or not is_nil(to)) + + _ -> + false + end) + end + + defp member_matches_custom_date?(member, custom_field_id, %{from: from, to: to}) do + case extract_member_date(member, custom_field_id) do + %Date{} = date -> within_bounds?(date, from, to) + _ -> false + end + end + + defp extract_member_date(member, custom_field_id) do + case Map.get(member, :custom_field_values) do + values when is_list(values) -> + values + |> Enum.find(&cfv_matches_id?(&1, custom_field_id)) + |> extract_date_from_cfv() + + _ -> + nil + end + end + + defp cfv_matches_id?(%{custom_field_id: cfid}, id) when not is_nil(cfid), + do: to_string(cfid) == id + + defp cfv_matches_id?(%{custom_field: %{id: cfid}}, id) when not is_nil(cfid), + do: to_string(cfid) == id + + defp cfv_matches_id?(_, _), do: false + + defp extract_date_from_cfv(nil), do: nil + + defp extract_date_from_cfv(%{value: value}), do: extract_date_value(value) + + defp extract_date_from_cfv(_), do: nil + + defp extract_date_value(%Ash.Union{value: %Date{} = date, type: :date}), do: date + defp extract_date_value(_), do: nil + + defp within_bounds?(%Date{} = date, from, to) do + from_ok? = is_nil(from) or Date.compare(date, from) != :lt + to_ok? = is_nil(to) or Date.compare(date, to) != :gt + from_ok? and to_ok? + end + + defp put_join_date_params(params, %{from: from, to: to}) do + params + |> maybe_put_date(@join_date_from_param, from) + |> maybe_put_date(@join_date_to_param, to) + end + + defp put_join_date_params(params, _), do: params + + defp put_exit_date_params(params, %{mode: :active_only}), do: params + + defp put_exit_date_params(params, %{mode: mode}) + when mode in [:all, :inactive_only] do + Map.put(params, @exit_date_mode_param, Atom.to_string(mode)) + end + + defp put_exit_date_params(params, %{mode: :custom, from: from, to: to}) do + params + |> Map.put(@exit_date_mode_param, "custom") + |> maybe_put_date(@exit_date_from_param, from) + |> maybe_put_date(@exit_date_to_param, to) + end + + defp put_exit_date_params(params, _), do: params + + defp put_custom_date_params(params, filters) do + prefix = @custom_date_filter_prefix + + filters + |> Enum.filter(fn {key, _value} -> is_binary(key) end) + |> Enum.reduce(params, fn {id, %{from: from, to: to}}, acc -> + acc + |> maybe_put_date("#{prefix}#{id}_from", from) + |> maybe_put_date("#{prefix}#{id}_to", to) + end) + end + + defp maybe_put_date(params, _key, nil), do: params + + defp maybe_put_date(params, key, %Date{} = date), + do: Map.put(params, key, Date.to_iso8601(date)) + + defp parse_date(nil), do: nil + + defp parse_date(value) when is_binary(value) do + case Date.from_iso8601(String.trim(value)) do + {:ok, date} -> date + _ -> nil + end + end + + defp parse_date(_), do: nil + + defp parse_exit_date_mode("all"), do: :all + defp parse_exit_date_mode("inactive_only"), do: :inactive_only + defp parse_exit_date_mode("custom"), do: :custom + defp parse_exit_date_mode("active_only"), do: :active_only + defp parse_exit_date_mode(_), do: :active_only + + defp parse_custom_date_filters(params, date_custom_fields, base) do + valid_ids = + date_custom_fields + |> Enum.filter(&date_field?/1) + |> MapSet.new(&to_string(field_id(&1))) + + prefix = @custom_date_filter_prefix + + params + |> Enum.reduce(base, fn {key, value}, acc -> + with true <- is_binary(key), + true <- String.starts_with?(key, prefix), + {id, bound} <- split_custom_date_key(key, prefix), + true <- MapSet.member?(valid_ids, id), + %Date{} = date <- parse_date(value) do + update_custom_date_entry(acc, id, bound, date) + else + _ -> acc + end + end) + end + + defp date_field?(%{value_type: :date}), do: true + defp date_field?(_), do: false + + defp field_id(%{id: id}), do: id + + defp split_custom_date_key(key, prefix) do + rest = String.slice(key, String.length(prefix), String.length(key)) + + cond do + String.ends_with?(rest, "_from") -> + {String.slice(rest, 0, String.length(rest) - 5), :from} + + String.ends_with?(rest, "_to") -> + {String.slice(rest, 0, String.length(rest) - 3), :to} + + true -> + :error + end + end + + defp update_custom_date_entry(acc, id, bound, date) do + current = Map.get(acc, id, %{from: nil, to: nil}) + Map.put(acc, id, Map.put(current, bound, date)) + end +end diff --git a/test/mv/constants_test.exs b/test/mv/constants_test.exs new file mode 100644 index 0000000..4ae689f --- /dev/null +++ b/test/mv/constants_test.exs @@ -0,0 +1,33 @@ +defmodule Mv.ConstantsTest do + @moduledoc """ + Tests for Mv.Constants accessor functions. Focus is on the date filter + URL parameter prefixes that drive the bookmarkable filter state. + """ + use ExUnit.Case, async: true + + describe "date filter URL param prefixes" do + test "join_date_from_param/0 returns jd_from" do + assert Mv.Constants.join_date_from_param() == "jd_from" + end + + test "join_date_to_param/0 returns jd_to" do + assert Mv.Constants.join_date_to_param() == "jd_to" + end + + test "exit_date_mode_param/0 returns ed_mode" do + assert Mv.Constants.exit_date_mode_param() == "ed_mode" + end + + test "exit_date_from_param/0 returns ed_from" do + assert Mv.Constants.exit_date_from_param() == "ed_from" + end + + test "exit_date_to_param/0 returns ed_to" do + assert Mv.Constants.exit_date_to_param() == "ed_to" + end + + test "custom_date_filter_prefix/0 returns cdf_" do + assert Mv.Constants.custom_date_filter_prefix() == "cdf_" + end + end +end diff --git a/test/mv_web/live/member_live/date_filter_custom_field_test.exs b/test/mv_web/live/member_live/date_filter_custom_field_test.exs new file mode 100644 index 0000000..2959e77 --- /dev/null +++ b/test/mv_web/live/member_live/date_filter_custom_field_test.exs @@ -0,0 +1,210 @@ +defmodule MvWeb.MemberLive.Index.DateFilterCustomFieldTest do + @moduledoc """ + Unit tests for `DateFilter.apply_in_memory/3` — the post-`Ash.read!` + predicate that filters members by custom date field values stored as + JSONB `Ash.Union` types in `custom_field_values`. + """ + use ExUnit.Case, async: true + + alias MvWeb.MemberLive.Index.DateFilter + + # ---- helpers --------------------------------------------------------- + + defp date_custom_field(id, name \\ "Birthday") do + %{id: id, value_type: :date, name: name} + end + + defp date_cfv(custom_field_id, %Date{} = date) do + %{ + custom_field_id: custom_field_id, + value: %Ash.Union{value: date, type: :date} + } + end + + defp member_with_dates(id, custom_field_values) do + %{id: id, custom_field_values: custom_field_values} + end + + # ---- no-op cases ----------------------------------------------------- + + describe "apply_in_memory/3 — no-op cases" do + test "returns members unchanged when filters has no custom date entries" do + filters = DateFilter.default() + members = [member_with_dates("m1", []), member_with_dates("m2", [])] + assert DateFilter.apply_in_memory(members, filters, []) == members + end + + test "ignores custom date entries whose UUID is not in the date_custom_fields list" do + id = "11111111-2222-3333-4444-555555555555" + other_id = "99999999-8888-7777-6666-555555555555" + + filters = + DateFilter.default() + |> Map.put(other_id, %{from: ~D[2024-01-01], to: nil}) + + m = member_with_dates("m1", [date_cfv(id, ~D[2023-01-01])]) + assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == [m] + end + + test "entry with both bounds nil is treated as inactive" do + id = "11111111-2222-3333-4444-555555555555" + + filters = + DateFilter.default() + |> Map.put(id, %{from: nil, to: nil}) + + m = member_with_dates("m1", [date_cfv(id, ~D[2023-01-01])]) + assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == [m] + end + end + + # ---- inclusive range semantics -------------------------------------- + + describe "apply_in_memory/3 — inclusive range semantics" do + setup do + id = "11111111-2222-3333-4444-555555555555" + + members = [ + member_with_dates("before", [date_cfv(id, ~D[2024-05-31])]), + member_with_dates("from_boundary", [date_cfv(id, ~D[2024-06-01])]), + member_with_dates("inside", [date_cfv(id, ~D[2024-06-15])]), + member_with_dates("to_boundary", [date_cfv(id, ~D[2024-06-30])]), + member_with_dates("after", [date_cfv(id, ~D[2024-07-01])]) + ] + + %{id: id, members: members, fields: [date_custom_field(id)]} + end + + test "from-only includes member when value >= from (boundary inclusive)", ctx do + filters = + DateFilter.default() + |> Map.put(ctx.id, %{from: ~D[2024-06-01], to: nil}) + + ids = + ctx.members + |> DateFilter.apply_in_memory(filters, ctx.fields) + |> Enum.map(& &1.id) + + assert ids == ["from_boundary", "inside", "to_boundary", "after"] + end + + test "from-only excludes member when value < from", ctx do + filters = + DateFilter.default() + |> Map.put(ctx.id, %{from: ~D[2024-06-01], to: nil}) + + refute Enum.any?( + DateFilter.apply_in_memory(ctx.members, filters, ctx.fields), + &(&1.id == "before") + ) + end + + test "to-only includes member when value <= to (boundary inclusive)", ctx do + filters = + DateFilter.default() + |> Map.put(ctx.id, %{from: nil, to: ~D[2024-06-30]}) + + ids = + ctx.members + |> DateFilter.apply_in_memory(filters, ctx.fields) + |> Enum.map(& &1.id) + + assert ids == ["before", "from_boundary", "inside", "to_boundary"] + end + + test "from+to applies an inclusive range", ctx do + filters = + DateFilter.default() + |> Map.put(ctx.id, %{from: ~D[2024-06-01], to: ~D[2024-06-30]}) + + ids = + ctx.members + |> DateFilter.apply_in_memory(filters, ctx.fields) + |> Enum.map(& &1.id) + + assert ids == ["from_boundary", "inside", "to_boundary"] + end + end + + # ---- exclusion of members without a value --------------------------- + + describe "apply_in_memory/3 — members without a value" do + test "excludes member with no custom_field_values when bound is active" do + id = "11111111-2222-3333-4444-555555555555" + + filters = + DateFilter.default() + |> Map.put(id, %{from: ~D[2024-01-01], to: nil}) + + members = [ + member_with_dates("present", [date_cfv(id, ~D[2024-06-01])]), + member_with_dates("nil_list", nil), + member_with_dates("empty_list", []), + # member missing the specific field but having other CFVs: + member_with_dates("other_field", [ + date_cfv("other-aaaa-bbbb-cccc-dddddddddddd", ~D[2024-06-01]) + ]) + ] + + ids = + members + |> DateFilter.apply_in_memory(filters, [date_custom_field(id)]) + |> Enum.map(& &1.id) + + assert ids == ["present"] + end + + test "treats Ash.NotLoaded custom_field_values as no value (excluded)" do + id = "11111111-2222-3333-4444-555555555555" + + filters = + DateFilter.default() + |> Map.put(id, %{from: ~D[2024-01-01], to: nil}) + + m = %{id: "m1", custom_field_values: %Ash.NotLoaded{}} + assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == [] + end + end + + # ---- Ash.Union unwrapping ------------------------------------------- + + describe "apply_in_memory/3 — Ash.Union unwrapping" do + test "unwraps %Ash.Union{value: %Date{}, type: :date}" do + id = "11111111-2222-3333-4444-555555555555" + + filters = + DateFilter.default() + |> Map.put(id, %{from: ~D[2024-06-01], to: nil}) + + m = + member_with_dates("m1", [ + %{ + custom_field_id: id, + value: %Ash.Union{value: ~D[2024-06-15], type: :date} + } + ]) + + assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == [m] + end + + test "rejects values whose Ash.Union type is not :date" do + id = "11111111-2222-3333-4444-555555555555" + + filters = + DateFilter.default() + |> Map.put(id, %{from: ~D[2024-06-01], to: nil}) + + # A boolean-typed value for what the filter believes is a date field — + # treat as no value, exclude the member. + m = + member_with_dates("m1", [ + %{ + custom_field_id: id, + value: %Ash.Union{value: true, type: :boolean} + } + ]) + + assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == [] + end + end +end diff --git a/test/mv_web/live/member_live/date_filter_default_test.exs b/test/mv_web/live/member_live/date_filter_default_test.exs new file mode 100644 index 0000000..84400ef --- /dev/null +++ b/test/mv_web/live/member_live/date_filter_default_test.exs @@ -0,0 +1,24 @@ +defmodule MvWeb.MemberLive.Index.DateFilterDefaultTest do + @moduledoc """ + Unit tests for DateFilter.default/0 — the initial filter map used when + no URL params are present (fresh load) and after "Clear filters". + """ + use ExUnit.Case, async: true + + alias MvWeb.MemberLive.Index.DateFilter + + describe "default/0" do + test "returns :active_only mode for exit_date with nil bounds" do + assert %{exit_date: %{mode: :active_only, from: nil, to: nil}} = DateFilter.default() + end + + test "returns nil bounds for join_date" do + assert %{join_date: %{from: nil, to: nil}} = DateFilter.default() + end + + test "contains only :join_date and :exit_date top-level keys" do + defaults = DateFilter.default() + assert Map.keys(defaults) |> Enum.sort() == [:exit_date, :join_date] + end + end +end diff --git a/test/mv_web/live/member_live/date_filter_property_test.exs b/test/mv_web/live/member_live/date_filter_property_test.exs new file mode 100644 index 0000000..693531c --- /dev/null +++ b/test/mv_web/live/member_live/date_filter_property_test.exs @@ -0,0 +1,134 @@ +defmodule MvWeb.MemberLive.Index.DateFilterPropertyTest do + @moduledoc """ + Property tests for the pure functions on `DateFilter`: + + * `to_params/1` ∘ `from_params/2` must be the identity for all valid + built-in date filter states (§2.3). + + Custom date field entries are not part of this property because + `from_params/2` needs the caller-supplied `date_custom_fields` list to + validate UUIDs; the standalone property for the in-memory predicate (§2.4) + is covered in S8 after the predicate exists. + """ + use ExUnit.Case, async: true + use ExUnitProperties + + alias MvWeb.MemberLive.Index.DateFilter + + # Generators ----------------------------------------------------------- + + defp optional_date_gen do + one_of([ + constant(nil), + map(integer(-3650..3650), &Date.add(~D[2000-01-01], &1)) + ]) + end + + defp exit_date_mode_gen do + one_of([ + constant(:active_only), + constant(:all), + constant(:inactive_only), + constant(:custom) + ]) + end + + defp exit_date_state_gen do + gen all( + mode <- exit_date_mode_gen(), + from <- optional_date_gen(), + to <- optional_date_gen() + ) do + %{mode: mode, from: from, to: to} + end + end + + defp join_date_state_gen do + gen all( + from <- optional_date_gen(), + to <- optional_date_gen() + ) do + %{from: from, to: to} + end + end + + # Property ------------------------------------------------------------- + + defp bound_pair_with_at_least_one_set_gen do + gen all( + from <- optional_date_gen(), + to <- optional_date_gen(), + from != nil or to != nil + ) do + {from, to} + end + end + + defp value_date_gen do + map(integer(-3650..3650), &Date.add(~D[2000-01-01], &1)) + end + + # Property ------------------------------------------------------------- + + property "in-memory date filter matches the inclusive range predicate" do + id = "11111111-2222-3333-4444-555555555555" + field = %{id: id, value_type: :date, name: "Property field"} + + check all( + value <- value_date_gen(), + {from, to} <- bound_pair_with_at_least_one_set_gen() + ) do + filters = + DateFilter.default() + |> Map.put(id, %{from: from, to: to}) + + member = %{ + id: "m1", + custom_field_values: [ + %{ + custom_field_id: id, + value: %Ash.Union{value: value, type: :date} + } + ] + } + + result = DateFilter.apply_in_memory([member], filters, [field]) + + from_ok? = is_nil(from) or Date.compare(value, from) != :lt + to_ok? = is_nil(to) or Date.compare(value, to) != :gt + expected_included? = from_ok? and to_ok? + actually_included? = result == [member] + + assert actually_included? == expected_included? + end + end + + property "encoding then decoding built-in date filter state is identity" do + check all( + join_date <- join_date_state_gen(), + exit_date <- exit_date_state_gen() + ) do + filters = %{join_date: join_date, exit_date: exit_date} + + decoded = DateFilter.from_params(DateFilter.to_params(filters), []) + + # join_date round-trips verbatim. + assert decoded.join_date == join_date + + # exit_date semantics: + # * :active_only is the default and discards bounds — the canonical + # URL omits them, so decoding restores nil bounds. + # * :all and :inactive_only also drop bounds in the URL — same reason. + # * :custom preserves bounds. + expected_exit_date = + case exit_date.mode do + :active_only -> %{mode: :active_only, from: nil, to: nil} + :all -> %{mode: :all, from: nil, to: nil} + :inactive_only -> %{mode: :inactive_only, from: nil, to: nil} + :custom -> exit_date + end + + assert decoded.exit_date == expected_exit_date + end + end +end diff --git a/test/mv_web/live/member_live/date_filter_test.exs b/test/mv_web/live/member_live/date_filter_test.exs new file mode 100644 index 0000000..424e6f1 --- /dev/null +++ b/test/mv_web/live/member_live/date_filter_test.exs @@ -0,0 +1,316 @@ +defmodule MvWeb.MemberLive.Index.DateFilterTest do + @moduledoc """ + Unit tests for DateFilter URL codec and pure helpers. + DB-level filtering and in-memory custom field filtering are covered in + separate integration tests (date_filter_default_test, date_filter_test, + date_filter_custom_field_test). + """ + use ExUnit.Case, async: true + + alias MvWeb.MemberLive.Index.DateFilter + + # Synthesize the minimal shape of a date-typed custom field expected by + # from_params/2. Only :id and :value_type are inspected by the decoder. + defp date_custom_field(id) do + %{id: id, value_type: :date, name: "Birthday-#{id}"} + end + + describe "from_params/2 — built-in date fields" do + test "parses jd_from as a Date" do + params = %{"jd_from" => "2024-05-01"} + filters = DateFilter.from_params(params, []) + assert filters.join_date.from == ~D[2024-05-01] + assert filters.join_date.to == nil + end + + test "parses jd_to as a Date" do + params = %{"jd_to" => "2024-08-31"} + filters = DateFilter.from_params(params, []) + assert filters.join_date.to == ~D[2024-08-31] + assert filters.join_date.from == nil + end + + test "ignores malformed jd_from string" do + params = %{"jd_from" => "notadate"} + filters = DateFilter.from_params(params, []) + assert filters.join_date.from == nil + assert filters.join_date.to == nil + end + + test "ignores malformed jd_to string" do + params = %{"jd_to" => "2024-13-45"} + filters = DateFilter.from_params(params, []) + assert filters.join_date.to == nil + end + + test "parses ed_mode=all" do + params = %{"ed_mode" => "all"} + filters = DateFilter.from_params(params, []) + assert filters.exit_date.mode == :all + end + + test "parses ed_mode=inactive_only" do + params = %{"ed_mode" => "inactive_only"} + filters = DateFilter.from_params(params, []) + assert filters.exit_date.mode == :inactive_only + end + + test "parses ed_mode=custom with bounds" do + params = %{"ed_mode" => "custom", "ed_from" => "2024-01-01", "ed_to" => "2024-12-31"} + filters = DateFilter.from_params(params, []) + assert filters.exit_date.mode == :custom + assert filters.exit_date.from == ~D[2024-01-01] + assert filters.exit_date.to == ~D[2024-12-31] + end + + test "returns :active_only mode when ed_mode is absent" do + filters = DateFilter.from_params(%{}, []) + assert filters.exit_date.mode == :active_only + end + + test "treats unknown ed_mode value as :active_only" do + params = %{"ed_mode" => "gibberish"} + filters = DateFilter.from_params(params, []) + assert filters.exit_date.mode == :active_only + end + end + + describe "to_params/1 — built-in date fields" do + test "omits ed_mode when mode is :active_only (default)" do + params = DateFilter.to_params(DateFilter.default()) + refute Map.has_key?(params, "ed_mode") + refute Map.has_key?(params, "ed_from") + refute Map.has_key?(params, "ed_to") + refute Map.has_key?(params, "jd_from") + refute Map.has_key?(params, "jd_to") + end + + test "encodes ed_mode=all" do + filters = %{ + join_date: %{from: nil, to: nil}, + exit_date: %{mode: :all, from: nil, to: nil} + } + + params = DateFilter.to_params(filters) + assert params["ed_mode"] == "all" + end + + test "encodes ed_mode=inactive_only" do + filters = %{ + join_date: %{from: nil, to: nil}, + exit_date: %{mode: :inactive_only, from: nil, to: nil} + } + + params = DateFilter.to_params(filters) + assert params["ed_mode"] == "inactive_only" + end + + test "encodes ed_mode=custom with bounds" do + filters = %{ + join_date: %{from: nil, to: nil}, + exit_date: %{mode: :custom, from: ~D[2024-01-01], to: ~D[2024-12-31]} + } + + params = DateFilter.to_params(filters) + assert params["ed_mode"] == "custom" + assert params["ed_from"] == "2024-01-01" + assert params["ed_to"] == "2024-12-31" + end + + test "encodes jd_from as ISO-8601 string" do + filters = %{ + join_date: %{from: ~D[2024-05-01], to: nil}, + exit_date: %{mode: :active_only, from: nil, to: nil} + } + + params = DateFilter.to_params(filters) + assert params["jd_from"] == "2024-05-01" + refute Map.has_key?(params, "jd_to") + end + + test "encodes jd_to as ISO-8601 string" do + filters = %{ + join_date: %{from: nil, to: ~D[2024-08-31]}, + exit_date: %{mode: :active_only, from: nil, to: nil} + } + + params = DateFilter.to_params(filters) + assert params["jd_to"] == "2024-08-31" + refute Map.has_key?(params, "jd_from") + end + + test "omits exit_date bounds when mode is not :custom" do + # Bounds may linger in state for UX (preserve user's last input) but the + # URL should not advertise them while a non-custom mode is active. + filters = %{ + join_date: %{from: nil, to: nil}, + exit_date: %{mode: :all, from: ~D[2024-01-01], to: ~D[2024-12-31]} + } + + params = DateFilter.to_params(filters) + assert params["ed_mode"] == "all" + refute Map.has_key?(params, "ed_from") + refute Map.has_key?(params, "ed_to") + end + end + + describe "to_params/1 — custom date field entries" do + test "encodes from/to bounds with cdf__ prefix" do + id = "11111111-2222-3333-4444-555555555555" + + filters = + DateFilter.default() + |> Map.put(id, %{from: ~D[2024-06-01], to: ~D[2024-06-30]}) + + params = DateFilter.to_params(filters) + assert params["cdf_#{id}_from"] == "2024-06-01" + assert params["cdf_#{id}_to"] == "2024-06-30" + end + + test "omits nil bounds for custom date field entries" do + id = "11111111-2222-3333-4444-555555555555" + + filters = + DateFilter.default() + |> Map.put(id, %{from: ~D[2024-06-01], to: nil}) + + params = DateFilter.to_params(filters) + assert params["cdf_#{id}_from"] == "2024-06-01" + refute Map.has_key?(params, "cdf_#{id}_to") + end + + test "omits custom date field entry entirely when both bounds are nil" do + id = "11111111-2222-3333-4444-555555555555" + + filters = + DateFilter.default() + |> Map.put(id, %{from: nil, to: nil}) + + params = DateFilter.to_params(filters) + refute Map.has_key?(params, "cdf_#{id}_from") + refute Map.has_key?(params, "cdf_#{id}_to") + end + end + + describe "apply_ash_filter/2 — shape contract" do + # The behavioral correctness of apply_ash_filter/2 is verified in the + # integration tests (date_filter_default_test, date_filter_test). Here we + # only assert the shape contract: which inputs leave the query untouched, + # and which add a filter expression. Inspecting Ash internals is brittle — + # we only check `query.filter` is or is not nil. + + alias Mv.Membership.Member, as: MemberResource + + defp base_query, do: MemberResource + + test ":all mode and empty join_date is a no-op" do + filters = %{ + join_date: %{from: nil, to: nil}, + exit_date: %{mode: :all, from: nil, to: nil} + } + + query = DateFilter.apply_ash_filter(base_query(), filters) + # No filter was applied — Ash leaves filter as nil when nothing is added. + assert is_nil(query.filter) + end + + test "default (:active_only) adds an exit_date filter" do + query = DateFilter.apply_ash_filter(base_query(), DateFilter.default()) + refute is_nil(query.filter) + end + + test ":inactive_only adds an exit_date filter" do + filters = %{ + join_date: %{from: nil, to: nil}, + exit_date: %{mode: :inactive_only, from: nil, to: nil} + } + + query = DateFilter.apply_ash_filter(base_query(), filters) + refute is_nil(query.filter) + end + + test ":custom mode with bounds adds an exit_date filter" do + filters = %{ + join_date: %{from: nil, to: nil}, + exit_date: %{mode: :custom, from: ~D[2024-01-01], to: ~D[2024-12-31]} + } + + query = DateFilter.apply_ash_filter(base_query(), filters) + refute is_nil(query.filter) + end + + test ":custom mode with both bounds nil adds no filter" do + filters = %{ + join_date: %{from: nil, to: nil}, + exit_date: %{mode: :custom, from: nil, to: nil} + } + + query = DateFilter.apply_ash_filter(base_query(), filters) + assert is_nil(query.filter) + end + + test "join_date from adds a filter" do + filters = %{ + join_date: %{from: ~D[2024-01-01], to: nil}, + exit_date: %{mode: :all, from: nil, to: nil} + } + + query = DateFilter.apply_ash_filter(base_query(), filters) + refute is_nil(query.filter) + end + + test "join_date to adds a filter" do + filters = %{ + join_date: %{from: nil, to: ~D[2024-12-31]}, + exit_date: %{mode: :all, from: nil, to: nil} + } + + query = DateFilter.apply_ash_filter(base_query(), filters) + refute is_nil(query.filter) + end + + test "accepts a non-resource Ash.Query as input" do + filters = %{ + join_date: %{from: nil, to: nil}, + exit_date: %{mode: :inactive_only, from: nil, to: nil} + } + + # Should also work when given a pre-built Ash.Query (not just a resource). + query = + MemberResource + |> Ash.Query.new() + |> DateFilter.apply_ash_filter(filters) + + refute is_nil(query.filter) + end + end + + describe "from_params/2 — custom date field entries" do + test "includes entry for known custom date field UUID" do + id = "11111111-2222-3333-4444-555555555555" + params = %{"cdf_#{id}_from" => "2024-06-01", "cdf_#{id}_to" => "2024-06-30"} + filters = DateFilter.from_params(params, [date_custom_field(id)]) + assert filters[id] == %{from: ~D[2024-06-01], to: ~D[2024-06-30]} + end + + test "ignores UUID not in date_custom_fields list" do + id = "11111111-2222-3333-4444-555555555555" + other = "99999999-8888-7777-6666-555555555555" + params = %{"cdf_#{other}_from" => "2024-06-01"} + filters = DateFilter.from_params(params, [date_custom_field(id)]) + refute Map.has_key?(filters, other) + end + + test "ignores malformed custom date field bound" do + id = "11111111-2222-3333-4444-555555555555" + params = %{"cdf_#{id}_from" => "notadate"} + filters = DateFilter.from_params(params, [date_custom_field(id)]) + # Either no entry, or entry with nil bounds — both satisfy "silently ignored" + case Map.get(filters, id) do + nil -> :ok + %{from: nil, to: nil} -> :ok + other -> flunk("expected nil bound for malformed input, got #{inspect(other)}") + end + end + end +end