feat(member-live): wire date filters into LiveView lifecycle
This commit is contained in:
parent
ddd4a9a878
commit
e3295ab4b5
10 changed files with 1037 additions and 140 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
85
test/mv_web/live/member_live/index/filter_params_test.exs
Normal file
85
test/mv_web/live/member_live/index/filter_params_test.exs
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue