feat(member-live): wire date filters into LiveView lifecycle

This commit is contained in:
Moritz 2026-05-20 16:28:17 +02:00
parent ddd4a9a878
commit e3295ab4b5
10 changed files with 1037 additions and 140 deletions

View file

@ -3,6 +3,9 @@ defmodule MvWeb.MemberLive.Index.DateFilterCustomFieldTest do
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`.
Integration coverage against a real database lives in the second module
in this file (DateFilterCustomFieldIntegrationTest).
"""
use ExUnit.Case, async: true
@ -208,3 +211,109 @@ defmodule MvWeb.MemberLive.Index.DateFilterCustomFieldTest do
end
end
end
defmodule MvWeb.MemberLive.Index.DateFilterCustomFieldIntegrationTest do
@moduledoc """
Integration tests for custom date field filtering on /members (§1.13, §1.14).
Creates a real `:date`-typed CustomField plus members with corresponding
CustomFieldValue rows, then asserts visibility through the LiveView with
`cdf_<uuid>_from` and `cdf_<uuid>_to` URL params.
"""
# async: false because we mutate global custom_fields and custom_field_values tables.
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Membership.{CustomField, CustomFieldValue}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Birthday-#{System.unique_integer([:positive])}",
value_type: :date,
show_in_overview: true
})
|> Ash.create(actor: system_actor)
{:ok, alice} =
Mv.Membership.create_member(
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
actor: system_actor
)
{:ok, bob} =
Mv.Membership.create_member(
%{first_name: "Bob", last_name: "Brown", email: "bob@example.com"},
actor: system_actor
)
{:ok, carla} =
Mv.Membership.create_member(
%{first_name: "Carla", last_name: "Carter", email: "carla@example.com"},
actor: system_actor
)
{:ok, dan_no_value} =
Mv.Membership.create_member(
%{first_name: "Dan", last_name: "Dixon", email: "dan@example.com"},
actor: system_actor
)
create_cfv(system_actor, alice.id, field.id, ~D[2020-05-15])
create_cfv(system_actor, bob.id, field.id, ~D[2022-08-01])
create_cfv(system_actor, carla.id, field.id, ~D[2024-02-20])
%{
field: field,
alice: alice,
bob: bob,
carla: carla,
dan_no_value: dan_no_value
}
end
defp create_cfv(actor, member_id, custom_field_id, %Date{} = date) do
{:ok, cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member_id,
custom_field_id: custom_field_id,
value: %{"_union_type" => "date", "_union_value" => Date.to_iso8601(date)}
})
|> Ash.create(actor: actor, domain: Mv.Membership)
cfv
end
describe "custom date field URL filter" do
test "from-only includes members with value >= bound (§1.13)",
%{conn: conn, field: field, alice: alice, bob: bob, carla: carla} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?cdf_#{field.id}_from=2022-01-01")
refute html =~ alice.first_name
assert html =~ bob.first_name
assert html =~ carla.first_name
end
test "from+to applies inclusive range (§1.14)",
%{conn: conn, field: field, alice: alice, bob: bob, carla: carla} do
conn = conn_with_oidc_user(conn)
url = "/members?cdf_#{field.id}_from=2022-01-01&cdf_#{field.id}_to=2023-12-31"
{:ok, _view, html} = live(conn, url)
refute html =~ alice.first_name
assert html =~ bob.first_name
refute html =~ carla.first_name
end
test "excludes member with no value for the active custom date field (§1.13)",
%{conn: conn, field: field, dan_no_value: dan} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?cdf_#{field.id}_from=2000-01-01")
refute html =~ dan.first_name
end
end
end

View file

@ -22,3 +22,123 @@ defmodule MvWeb.MemberLive.Index.DateFilterDefaultTest do
end
end
end
defmodule MvWeb.MemberLive.Index.DateFilterDefaultIntegrationTest do
@moduledoc """
Integration tests for the default exit_date filter behavior on the member
overview page (§1.1, §1.2, §1.3, §1.4, §1.6 in the issue specs).
These exercise the full `mount/3` `handle_params/3` `load_members/1`
pipeline against a real database, asserting that the active-only default
is applied to a fresh page load and overridden when the URL says so.
"""
# async: false because we mutate the global member table.
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
today = Date.utc_today()
{:ok, active_no_exit} =
Mv.Membership.create_member(
%{first_name: "Anna", last_name: "Active", email: "anna@example.com"},
actor: system_actor
)
{:ok, future_exit} =
Mv.Membership.create_member(
%{
first_name: "Felix",
last_name: "Future",
email: "felix@example.com",
join_date: Date.add(today, -365),
exit_date: Date.add(today, 30)
},
actor: system_actor
)
{:ok, exit_today} =
Mv.Membership.create_member(
%{
first_name: "Tina",
last_name: "Today",
email: "tina@example.com",
join_date: Date.add(today, -365),
exit_date: today
},
actor: system_actor
)
{:ok, past_exit} =
Mv.Membership.create_member(
%{
first_name: "Paula",
last_name: "Past",
email: "paula@example.com",
join_date: Date.add(today, -365),
exit_date: Date.add(today, -1)
},
actor: system_actor
)
%{
active_no_exit: active_no_exit,
future_exit: future_exit,
exit_today: exit_today,
past_exit: past_exit
}
end
describe "fresh load — no URL params" do
test "hides member with exit_date strictly before today (§1.1)", %{
conn: conn,
past_exit: past
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
refute html =~ past.first_name
end
test "hides member whose exit_date equals today (§1.4)", %{
conn: conn,
exit_today: exit_today
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
refute html =~ exit_today.first_name
end
test "shows member with no exit_date (§1.2)", %{conn: conn, active_no_exit: anna} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ anna.first_name
end
test "shows member with exit_date strictly in the future (§1.3)", %{
conn: conn,
future_exit: felix
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ felix.first_name
end
end
describe "URL with ed_mode=all overrides the default (§1.6)" do
test "shows former members when URL contains ed_mode=all", %{
conn: conn,
past_exit: past,
exit_today: today,
active_no_exit: anna,
future_exit: felix
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?ed_mode=all")
assert html =~ past.first_name
assert html =~ today.first_name
assert html =~ anna.first_name
assert html =~ felix.first_name
end
end
end

View file

@ -1,9 +1,8 @@
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).
DB-level filtering against real members is covered by the integration
module below in this file.
"""
use ExUnit.Case, async: true
@ -201,7 +200,12 @@ defmodule MvWeb.MemberLive.Index.DateFilterTest do
alias Mv.Membership.Member, as: MemberResource
defp base_query, do: MemberResource
# The production caller (`MvWeb.MemberLive.Index.load_members/1`) hands
# `apply_ash_filter/2` an already-built `%Ash.Query{}`, matching the
# convention of the sibling `apply_*_filters` helpers. The shape contract
# tests mirror that convention so they exercise the exact call shape used
# in production.
defp base_query, do: Ash.Query.new(MemberResource)
test ":all mode and empty join_date is a no-op" do
filters = %{
@ -269,20 +273,102 @@ defmodule MvWeb.MemberLive.Index.DateFilterTest do
refute is_nil(query.filter)
end
test "accepts a non-resource Ash.Query as input" do
test "raises FunctionClauseError when caller passes a bare resource module" do
# The function now requires `%Ash.Query{}` — the production convention
# used by every sibling `apply_*_filters` helper in
# `MvWeb.MemberLive.Index`. A bare resource module is no longer accepted.
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :inactive_only, from: nil, to: nil}
exit_date: %{mode: :all, 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)
# Indirect through a variable so the compiler's static type analysis
# does not flag the deliberately invalid call shape we want to assert on.
bare_resource = Function.identity(MemberResource)
assert_raise FunctionClauseError, fn ->
DateFilter.apply_ash_filter(bare_resource, filters)
end
end
test "join_date map missing :to key still applies the from bound" do
# A caller-supplied map can omit one bound key entirely (not just set it
# to nil). The Ash filter must still be applied for the bound that is
# present.
filters = %{
join_date: %{from: ~D[2024-01-01]},
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 map missing :from key still applies the to bound" do
filters = %{
join_date: %{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 ":custom exit_date with only :from key still applies the bound" do
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :custom, from: ~D[2024-01-01]}
}
query = DateFilter.apply_ash_filter(base_query(), filters)
refute is_nil(query.filter)
end
test ":custom exit_date with only :to key still applies the bound" do
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :custom, to: ~D[2024-12-31]}
}
query = DateFilter.apply_ash_filter(base_query(), filters)
refute is_nil(query.filter)
end
end
describe "active_custom_field_ids/2" do
test "returns UUID keys with at least one bound set whose UUID matches a date field" do
id_a = "11111111-1111-1111-1111-111111111111"
id_b = "22222222-2222-2222-2222-222222222222"
id_unknown = "33333333-3333-3333-3333-333333333333"
filters =
DateFilter.default()
|> Map.put(id_a, %{from: ~D[2024-01-01], to: nil})
|> Map.put(id_b, %{from: nil, to: nil})
|> Map.put(id_unknown, %{from: ~D[2024-06-01], to: nil})
ids =
DateFilter.active_custom_field_ids(filters, [
date_custom_field(id_a),
date_custom_field(id_b)
])
assert ids == [id_a]
end
test "ignores non-binary keys (built-in atoms)" do
assert DateFilter.active_custom_field_ids(DateFilter.default(), []) == []
end
test "returns [] when no date custom fields are supplied" do
id_a = "11111111-1111-1111-1111-111111111111"
filters =
DateFilter.default()
|> Map.put(id_a, %{from: ~D[2024-01-01], to: nil})
assert DateFilter.active_custom_field_ids(filters, []) == []
end
end
describe "from_params/2 — custom date field entries" do
@ -312,5 +398,231 @@ defmodule MvWeb.MemberLive.Index.DateFilterTest do
other -> flunk("expected nil bound for malformed input, got #{inspect(other)}")
end
end
test "strips only the trailing _from / _to suffix, not internal substrings" do
# Construct an id that itself contains "_from" / "_to" — a quirky but
# legal MapSet key. Trailing-only suffix stripping must leave the
# internal substrings intact and recover the original id.
id_with_internal = "abc_from_xyz_to_def"
params = %{
"cdf_#{id_with_internal}_from" => "2024-06-01",
"cdf_#{id_with_internal}_to" => "2024-06-30"
}
filters =
DateFilter.from_params(params, [date_custom_field(id_with_internal)])
assert filters[id_with_internal] ==
%{from: ~D[2024-06-01], to: ~D[2024-06-30]}
end
test "drops cdf_-prefixed keys whose id exceeds the UUID length cap" do
# Matches the DoS-protection contract enforced by the sibling boolean,
# group, and fee_type filter parsers: an over-long id-portion (post
# prefix-strip, pre suffix-strip) is rejected without invoking the
# heavier String.replace_suffix / MapSet.member? path. The id we
# construct is well past `Mv.Constants.max_uuid_length()` (36).
known_id = "11111111-2222-3333-4444-555555555555"
over_long_id = String.duplicate("a", 200)
params = %{
"cdf_#{over_long_id}_from" => "2024-06-01",
"cdf_#{over_long_id}_to" => "2024-06-30"
}
filters = DateFilter.from_params(params, [date_custom_field(known_id)])
refute Map.has_key?(filters, over_long_id)
refute Map.has_key?(filters, "#{over_long_id}_from")
refute Map.has_key?(filters, "#{over_long_id}_to")
end
end
end
defmodule MvWeb.MemberLive.Index.DateFilterIntegrationTest do
@moduledoc """
Integration tests for the date filter URL query result-set pipeline.
Covers §1.7, §1.8, §1.9, §1.10, §1.11, §1.12, §1.15, §1.16, §1.17, §1.18,
§1.19. Custom date field filters are covered in the dedicated custom-field
integration test.
"""
# async: false: mutates the global member table.
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
today = Date.utc_today()
{:ok, alice} =
Mv.Membership.create_member(
%{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com",
join_date: ~D[2020-01-01]
},
actor: system_actor
)
{:ok, bob} =
Mv.Membership.create_member(
%{
first_name: "Bob",
last_name: "Brown",
email: "bob@example.com",
join_date: ~D[2022-06-15]
},
actor: system_actor
)
{:ok, carla} =
Mv.Membership.create_member(
%{
first_name: "Carla",
last_name: "Carter",
email: "carla@example.com",
join_date: ~D[2024-03-20]
},
actor: system_actor
)
{:ok, dan} =
Mv.Membership.create_member(
%{
first_name: "Dan",
last_name: "Dixon",
email: "dan@example.com"
# no join_date — should be excluded by any join_date range filter
},
actor: system_actor
)
{:ok, former} =
Mv.Membership.create_member(
%{
first_name: "Frida",
last_name: "Former",
email: "frida@example.com",
join_date: Date.add(today, -1000),
exit_date: Date.add(today, -10)
},
actor: system_actor
)
%{alice: alice, bob: bob, carla: carla, dan: dan, former: former, today: today}
end
describe "join_date filters" do
test "jd_from includes members with join_date >= bound; excludes nil (§1.7, §1.17)",
%{conn: conn, alice: alice, bob: bob, carla: carla, dan: dan} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?jd_from=2022-01-01")
refute html =~ alice.first_name
assert html =~ bob.first_name
assert html =~ carla.first_name
refute html =~ dan.first_name
end
test "jd_to excludes members with join_date > bound (§1.8)",
%{conn: conn, alice: alice, bob: bob, carla: carla, dan: dan} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?jd_to=2022-12-31")
assert html =~ alice.first_name
assert html =~ bob.first_name
refute html =~ carla.first_name
refute html =~ dan.first_name
end
test "jd_from and jd_to combine into an inclusive range (§1.9)",
%{conn: conn, alice: alice, bob: bob, carla: carla} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?jd_from=2022-01-01&jd_to=2023-12-31")
refute html =~ alice.first_name
assert html =~ bob.first_name
refute html =~ carla.first_name
end
test "no active filter imposes no constraint on the join_date field (§1.18)",
%{conn: conn, alice: alice, bob: bob, carla: carla, dan: dan} do
# Nil join_date members are still visible when no join_date filter is active.
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ alice.first_name
assert html =~ bob.first_name
assert html =~ carla.first_name
assert html =~ dan.first_name
end
end
describe "exit_date filters" do
test "ed_mode=inactive_only shows only former members (§1.10)",
%{conn: conn, alice: alice, former: former} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?ed_mode=inactive_only")
refute html =~ alice.first_name
assert html =~ former.first_name
end
test "ed_mode=all shows all members regardless of exit_date (§1.11)",
%{conn: conn, alice: alice, former: former} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?ed_mode=all")
assert html =~ alice.first_name
assert html =~ former.first_name
end
test "ed_mode=custom range hides members outside the range (§1.12)",
%{conn: conn, alice: alice, former: former, today: today} do
conn = conn_with_oidc_user(conn)
from = Date.add(today, -30) |> Date.to_iso8601()
to = Date.to_iso8601(today)
{:ok, _view, html} = live(conn, "/members?ed_mode=custom&ed_from=#{from}&ed_to=#{to}")
refute html =~ alice.first_name
assert html =~ former.first_name
end
end
describe "filter combination and URL persistence" do
test "join_date filter combined with the default (active-only) shows only active members in range (§1.15)",
%{conn: conn, alice: alice, bob: bob, former: former} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?jd_from=2020-01-01")
# Default active-only hides the former member even though they match join_date range.
refute html =~ former.first_name
assert html =~ alice.first_name
assert html =~ bob.first_name
end
test "date filter survives reload via URL params (§1.16)",
%{conn: conn, alice: alice, bob: bob, carla: carla} do
conn = conn_with_oidc_user(conn)
url = "/members?jd_from=2022-01-01&jd_to=2023-12-31"
{:ok, _view, html1} = live(conn, url)
{:ok, _view, html2} = live(conn, url)
# Same URL → same visible result set.
for member <- [alice, carla] do
refute html1 =~ member.first_name
refute html2 =~ member.first_name
end
assert html1 =~ bob.first_name
assert html2 =~ bob.first_name
end
test "malformed jd_from is silently ignored (§1.19)",
%{conn: conn, alice: alice, bob: bob, carla: carla, dan: dan} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?jd_from=notadate")
# The filter is dropped, so default behavior (no join_date filter) applies
# and every member shows up.
assert html =~ alice.first_name
assert html =~ bob.first_name
assert html =~ carla.first_name
assert html =~ dan.first_name
end
end
end

View file

@ -0,0 +1,89 @@
defmodule MvWeb.MemberLive.Index.CustomFieldValueLookupTest do
@moduledoc """
Unit tests for the shared custom-field-value lookup helper.
The lookup must handle both shapes a CFV entry can take on a loaded member:
* `%{custom_field_id: id, value: ...}` id present directly
* `%{custom_field: %{id: id, ...}, value: ...}` id nested under loaded relation
"""
use ExUnit.Case, async: true
alias MvWeb.MemberLive.Index.CustomFieldValueLookup
defp uuid, do: "11111111-2222-3333-4444-555555555555"
describe "find_by_id/2" do
test "matches when custom_field_id key is present" do
id = uuid()
cfv = %{custom_field_id: id, value: :anything}
member = %{custom_field_values: [cfv]}
assert CustomFieldValueLookup.find_by_id(member, id) == cfv
end
test "matches when nested custom_field relation is loaded" do
id = uuid()
cfv = %{custom_field: %{id: id, value_type: :date}, value: :anything}
member = %{custom_field_values: [cfv]}
assert CustomFieldValueLookup.find_by_id(member, id) == cfv
end
test "compares stringified ids — accepts atom or binary ids on the cfv side" do
id = uuid()
cfv = %{custom_field_id: id, value: :v}
member = %{custom_field_values: [cfv]}
# Same id, passed as binary
assert CustomFieldValueLookup.find_by_id(member, id) == cfv
end
test "returns nil when no entry has a matching id" do
member = %{
custom_field_values: [
%{custom_field_id: "11111111-1111-1111-1111-111111111111", value: 1}
]
}
assert CustomFieldValueLookup.find_by_id(member, uuid()) == nil
end
test "returns nil when custom_field_values is nil" do
assert CustomFieldValueLookup.find_by_id(%{custom_field_values: nil}, uuid()) == nil
end
test "returns nil when custom_field_values is not loaded (Ash.NotLoaded)" do
member = %{custom_field_values: %Ash.NotLoaded{type: :relationship}}
assert CustomFieldValueLookup.find_by_id(member, uuid()) == nil
end
test "returns nil when custom_field_values is empty" do
assert CustomFieldValueLookup.find_by_id(%{custom_field_values: []}, uuid()) == nil
end
end
describe "find_by_field/2" do
test "matches a custom_field struct via its :id" do
id = uuid()
cfv = %{custom_field_id: id, value: :v}
member = %{custom_field_values: [cfv]}
custom_field = %{id: id, value_type: :boolean}
assert CustomFieldValueLookup.find_by_field(member, custom_field) == cfv
end
test "matches when only the nested custom_field is present" do
id = uuid()
cfv = %{custom_field: %{id: id}, value: :v}
member = %{custom_field_values: [cfv]}
assert CustomFieldValueLookup.find_by_field(member, %{id: id}) == cfv
end
test "returns nil when no entry matches" do
member = %{custom_field_values: [%{custom_field_id: "other", value: :v}]}
assert CustomFieldValueLookup.find_by_field(member, %{id: uuid()}) == nil
end
end
end

View file

@ -0,0 +1,85 @@
defmodule MvWeb.MemberLive.Index.FilterParamsTest do
@moduledoc """
Unit tests for the shared filter-param parsers.
"""
use ExUnit.Case, async: true
alias MvWeb.MemberLive.Index.FilterParams
describe "parse_prefix_filters/3" do
test "extracts only entries whose key starts with the prefix" do
params = %{
"group_abc" => "in",
"group_def" => "not_in",
"fee_type_xyz" => "in",
"unrelated" => "in",
"query" => "alice"
}
result = FilterParams.parse_prefix_filters(params, "group_", & &1)
assert result == %{"abc" => "in", "def" => "not_in"}
end
test "strips exactly one occurrence of the prefix, even when the rest starts with the prefix again" do
# Quirky but legal: a key like "p_p_abc" with prefix "p_" must produce id "p_abc".
params = %{"p_p_abc" => "v"}
result = FilterParams.parse_prefix_filters(params, "p_", & &1)
assert result == %{"p_abc" => "v"}
end
test "applies parse_value_fn to every value" do
params = %{"x_one" => "in", "x_two" => "not_in", "x_three" => "garbage"}
result =
FilterParams.parse_prefix_filters(
params,
"x_",
&FilterParams.parse_in_not_in_value/1
)
assert result == %{"one" => :in, "two" => :not_in, "three" => nil}
end
test "returns empty map when no key matches the prefix" do
params = %{"a" => "1", "b" => "2"}
assert FilterParams.parse_prefix_filters(params, "z_", & &1) == %{}
end
test "ignores non-binary keys" do
params = %{"x_a" => "1", :atom_key => "2", 123 => "3"}
result = FilterParams.parse_prefix_filters(params, "x_", & &1)
assert result == %{"a" => "1"}
end
test "returns empty map for empty input" do
assert FilterParams.parse_prefix_filters(%{}, "x_", & &1) == %{}
end
end
describe "parse_in_not_in_value/1" do
test "maps 'in' to :in" do
assert FilterParams.parse_in_not_in_value("in") == :in
end
test "maps 'not_in' to :not_in" do
assert FilterParams.parse_in_not_in_value("not_in") == :not_in
end
test "trims whitespace around recognized values" do
assert FilterParams.parse_in_not_in_value(" in ") == :in
assert FilterParams.parse_in_not_in_value("\tnot_in\n") == :not_in
end
test "returns nil for unrecognized strings" do
assert FilterParams.parse_in_not_in_value("yes") == nil
assert FilterParams.parse_in_not_in_value("") == nil
end
test "returns nil for non-binary input" do
assert FilterParams.parse_in_not_in_value(nil) == nil
assert FilterParams.parse_in_not_in_value(:in) == nil
assert FilterParams.parse_in_not_in_value(123) == nil
end
end
end