refactor(member-live): modularize the overview and drop the superseded in-memory paths

Extract the cookie parser, export-payload builder and fee-status helper
into their own modules and remove the filter/sort/load helpers the
database pushdown made dead, so the overview LiveView stays a thin
coordinator over focused units.
This commit is contained in:
Simon 2026-07-06 10:54:09 +02:00
parent c2cb3edab8
commit 77fc11a0b0
17 changed files with 375 additions and 875 deletions

View file

@ -0,0 +1,22 @@
defmodule MvWeb.MemberLive.Index.CookieTest do
@moduledoc """
Tests for the shared cookie-header parser used by the overview persistence chain.
"""
use ExUnit.Case, async: true
alias MvWeb.MemberLive.Index.Cookie
describe "parse_header/1" do
test "splits pairs and URL-decodes values" do
assert Cookie.parse_header("a=1; b=hello%20world") == %{"a" => "1", "b" => "hello world"}
end
test "maps a valueless entry to an empty string" do
assert Cookie.parse_header("flag; a=1") == %{"flag" => "", "a" => "1"}
end
test "keeps only the first '=' as the separator" do
assert Cookie.parse_header("token=a=b=c") == %{"token" => "a=b=c"}
end
end
end

View file

@ -0,0 +1,87 @@
defmodule MvWeb.MemberLive.Index.ExportPayloadTest do
@moduledoc """
Tests for the member-overview export descriptor builder: the column order it
emits must mirror the rendered table (DB fields in constants order, fee
type/status after the fee start date, groups before custom fields), and the
filter/sort/selection state must be serialized faithfully.
"""
use ExUnit.Case, async: true
alias MvWeb.MemberLive.Index.ExportPayload
defp custom_field(id), do: %{id: id}
describe "build/1" do
test "orders columns like the table: db fields, fee type/status, groups, custom fields" do
assigns = %{
selected_members: MapSet.new(["m1", "m2"]),
member_fields_visible_db: [:first_name, :membership_fee_start_date, :last_name],
member_fields_visible_computed: [:membership_fee_status],
member_fields_visible: [
:first_name,
:last_name,
:membership_fee_start_date,
:membership_fee_type,
:membership_fee_status,
:groups
],
visible_custom_field_ids: ["cf-a"],
all_custom_fields: [custom_field("cf-a"), custom_field("cf-b")],
query: "ada",
sort_field: :last_name,
sort_order: :desc,
show_current_cycle: true,
cycle_status_filter: :unpaid,
boolean_custom_field_filters: %{"cf-x" => true}
}
payload = ExportPayload.build(assigns)
# DB fields follow Mv.Constants.member_fields/0 order (first_name, last_name,
# then membership_fee_start_date), membership_fee_type + status are inserted
# right after the fee start date, groups precede the custom fields, and custom
# fields keep table order (only visible ones).
assert payload.column_order == [
"first_name",
"last_name",
"membership_fee_start_date",
"membership_fee_type",
"membership_fee_status",
"groups",
"cf-a"
]
assert Enum.sort(payload.selected_ids) == ["m1", "m2"]
assert payload.custom_field_ids == ["cf-a"]
assert payload.query == "ada"
assert payload.sort_field == "last_name"
assert payload.sort_order == "desc"
assert payload.show_current_cycle == true
assert payload.cycle_status_filter == "unpaid"
assert payload.boolean_filters == %{"cf-x" => true}
end
test "defaults optional state and omits groups/custom fields when not visible" do
assigns = %{
selected_members: MapSet.new(),
member_fields_visible_db: [:first_name],
member_fields_visible_computed: [],
member_fields_visible: [:first_name],
visible_custom_field_ids: [],
all_custom_fields: [custom_field("cf-a")]
}
payload = ExportPayload.build(assigns)
assert payload.column_order == ["first_name"]
assert payload.selected_ids == []
assert payload.custom_field_ids == []
assert payload.query == nil
assert payload.sort_field == nil
assert payload.sort_order == nil
assert payload.show_current_cycle == false
assert payload.cycle_status_filter == nil
assert payload.boolean_filters == %{}
end
end
end

View file

@ -6,201 +6,6 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do
alias MvWeb.MemberLive.Index.FieldVisibility
# Mock custom field structs for testing
defp create_custom_field(id, name, show_in_overview \\ true) do
%{
id: id,
name: name,
show_in_overview: show_in_overview
}
end
describe "get_all_available_fields/1" do
test "returns member fields and custom fields" do
custom_fields = [
create_custom_field("cf1", "Custom Field 1"),
create_custom_field("cf2", "Custom Field 2")
]
result = FieldVisibility.get_all_available_fields(custom_fields)
# Should include all member fields
assert :first_name in result
assert :email in result
assert :street in result
# Should include custom fields as strings
assert "custom_field_cf1" in result
assert "custom_field_cf2" in result
end
test "handles empty custom fields list" do
result = FieldVisibility.get_all_available_fields([])
# Should only have member fields
assert :first_name in result
assert :email in result
refute Enum.any?(result, fn field ->
is_binary(field) and String.starts_with?(field, "custom_field_")
end)
end
test "includes all member fields from constants" do
custom_fields = []
result = FieldVisibility.get_all_available_fields(custom_fields)
member_fields = Mv.Constants.member_fields()
Enum.each(member_fields, fn field ->
assert field in result
end)
end
test "includes pseudo member fields (membership_fee_status, membership_fee_type, groups)" do
result = FieldVisibility.get_all_available_fields([])
assert :membership_fee_status in result
assert :membership_fee_type in result
assert :groups in result
end
end
describe "merge_with_global_settings/3" do
test "user selection overrides global settings" do
user_selection = %{"first_name" => false}
settings = %{member_field_visibility: %{first_name: true, email: true}}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["first_name"] == false
assert result["email"] == true
end
test "falls back to global settings when user selection is empty" do
user_selection = %{}
settings = %{member_field_visibility: %{first_name: false, email: true}}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["first_name"] == false
assert result["email"] == true
end
test "defaults to the curated set when field not in settings" do
user_selection = %{}
settings = %{member_field_visibility: %{join_date: false}}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
# join_date from settings overrides its curated default
assert result["join_date"] == false
# name is in the curated default set -> visible
assert result["name"] == true
# email is not in the curated default set -> hidden
assert result["email"] == false
end
test "handles custom fields visibility" do
user_selection = %{}
settings = %{member_field_visibility: %{}}
custom_fields = [
create_custom_field("cf1", "Custom 1", true),
create_custom_field("cf2", "Custom 2", false)
]
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["custom_field_cf1"] == true
assert result["custom_field_cf2"] == false
end
test "user selection overrides custom field visibility" do
user_selection = %{"custom_field_cf1" => false}
settings = %{member_field_visibility: %{}}
custom_fields = [
create_custom_field("cf1", "Custom 1", true)
]
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["custom_field_cf1"] == false
end
test "handles string keys in settings (JSONB format)" do
user_selection = %{}
settings = %{member_field_visibility: %{"first_name" => false, "email" => true}}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["first_name"] == false
assert result["email"] == true
end
test "handles mixed atom and string keys in settings" do
user_selection = %{}
# Use string keys only (as JSONB would return)
settings = %{member_field_visibility: %{"first_name" => false, "email" => true}}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["first_name"] == false
assert result["email"] == true
end
test "handles nil settings gracefully" do
user_selection = %{}
settings = %{member_field_visibility: nil}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
# Falls back to the curated default set (no crash on nil)
assert result["join_date"] == true
assert result["email"] == false
end
test "handles missing member_field_visibility key" do
user_selection = %{}
settings = %{}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
# Falls back to the curated default set
assert result["join_date"] == true
assert result["email"] == false
end
test "includes all fields in result" do
user_selection = %{"first_name" => false}
settings = %{member_field_visibility: %{email: true}}
custom_fields = [
create_custom_field("cf1", "Custom 1", true)
]
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
# Should include all member fields
member_fields = Mv.Constants.member_fields()
Enum.each(member_fields, fn field ->
assert Map.has_key?(result, Atom.to_string(field))
end)
# Should include custom fields
assert Map.has_key?(result, "custom_field_cf1")
end
end
describe "offered_member_fields/3 ordering (name group, then email, then address group, then rest)" do
test "compact member + compact address: Name, E-Mail, Adresse lead" do
result = FieldVisibility.offered_member_fields(true, false, true)
@ -251,55 +56,6 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do
defp index_of(list, elem), do: Enum.find_index(list, &(&1 == elem))
describe "get_visible_fields/1" do
test "returns only fields with true visibility" do
selection = %{
"first_name" => true,
"email" => false,
"street" => true,
"custom_field_123" => false
}
result = FieldVisibility.get_visible_fields(selection)
assert :first_name in result
assert :street in result
refute :email in result
refute "custom_field_123" in result
end
test "converts member field strings to atoms" do
selection = %{"first_name" => true, "email" => true}
result = FieldVisibility.get_visible_fields(selection)
assert :first_name in result
assert :email in result
end
test "keeps custom fields as strings" do
selection = %{"custom_field_abc-123" => true}
result = FieldVisibility.get_visible_fields(selection)
assert "custom_field_abc-123" in result
end
test "handles empty selection" do
assert FieldVisibility.get_visible_fields(%{}) == []
end
test "handles all fields hidden" do
selection = %{"first_name" => false, "email" => false}
assert FieldVisibility.get_visible_fields(selection) == []
end
test "handles invalid input" do
assert FieldVisibility.get_visible_fields(nil) == []
end
end
describe "get_visible_member_fields/1" do
test "returns only member fields that are visible" do
selection = %{

View file

@ -0,0 +1,313 @@
defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
@moduledoc """
Tests for MembershipFeeStatus helper module.
"""
use Mv.DataCase, async: false
import Mv.Fixtures, only: [create_fee_type: 1, create_cycle: 3]
alias MvWeb.MemberLive.Index.MembershipFeeStatus
require Ash.Query
# Helper to create a member
defp create_member(attrs) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
}
attrs = Map.merge(default_attrs, attrs)
{:ok, member} = Mv.Membership.create_member(attrs, actor: system_actor)
member
end
describe "get_cycle_status_for_member/2" do
test "returns status of last completed cycle" do
fee_type = create_fee_type(%{interval: :yearly})
# Create member without fee type to avoid auto-generation
member = create_member(%{})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Assign fee type
member =
member
|> then(fn m ->
{:ok, updated} =
Mv.Membership.update_member(m, %{membership_fee_type_id: fee_type.id},
actor: system_actor
)
updated
end)
# Delete any auto-generated cycles
cycles =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!(actor: system_actor)
Enum.each(cycles, fn cycle -> Ash.destroy!(cycle, actor: system_actor) end)
# Create cycles with dates that ensure 2023 is last completed
# Use a fixed "today" date in 2024 to make 2023 the last completed
create_cycle(member, fee_type, %{cycle_start: ~D[2022-01-01], status: :paid})
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
# Load cycles with membership_fee_type relationship
system_actor = Mv.Helpers.SystemActor.get_system_actor()
member =
member
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type, actor: system_actor)
# Use fixed date in 2024 to ensure 2023 is last completed
# We need to manually set the date for the helper function
# Since get_cycle_status_for_member doesn't take a date, we need to ensure
# the cycles are properly loaded with their fee_type relationship
status = MembershipFeeStatus.get_cycle_status_for_member(member, false)
# The status depends on what Date.utc_today() returns
# If we're in 2024 or later, 2023 should be last completed
# If we're still in 2023, 2022 would be last completed
# For this test, we'll just verify it returns a valid status
assert status in [:paid, :unpaid, :suspended, nil]
end
test "returns status of current cycle when show_current is true" do
fee_type = create_fee_type(%{interval: :yearly})
# Create member without fee type to avoid auto-generation
member = create_member(%{})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Assign fee type
member =
member
|> then(fn m ->
{:ok, updated} =
Mv.Membership.update_member(m, %{membership_fee_type_id: fee_type.id},
actor: system_actor
)
updated
end)
# Delete any auto-generated cycles
cycles =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!(actor: system_actor)
Enum.each(cycles, fn cycle -> Ash.destroy!(cycle, actor: system_actor) end)
# Create cycles - use current year for current cycle
today = Date.utc_today()
current_year_start = %{today | month: 1, day: 1}
last_year_start = %{current_year_start | year: current_year_start.year - 1}
create_cycle(member, fee_type, %{cycle_start: last_year_start, status: :paid})
create_cycle(member, fee_type, %{cycle_start: current_year_start, status: :suspended})
# Load cycles with membership_fee_type relationship
member =
member
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type, actor: system_actor)
status = MembershipFeeStatus.get_cycle_status_for_member(member, true)
# Should return status of current cycle
assert status == :suspended
end
test "returns nil if no cycles exist" do
fee_type = create_fee_type(%{interval: :yearly})
# Create member without fee type to avoid auto-generation
member = create_member(%{})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Assign fee type
member =
member
|> then(fn m ->
{:ok, updated} =
Mv.Membership.update_member(m, %{membership_fee_type_id: fee_type.id},
actor: system_actor
)
updated
end)
# Delete any auto-generated cycles
cycles =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!(actor: system_actor)
Enum.each(cycles, fn cycle -> Ash.destroy!(cycle, actor: system_actor) end)
# Load cycles and fee type first (will be empty)
member =
member
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type, actor: system_actor)
status = MembershipFeeStatus.get_cycle_status_for_member(member, false)
assert status == nil
end
end
describe "format_cycle_status_badge/1" do
test "returns badge component for paid status" do
result = MembershipFeeStatus.format_cycle_status_badge(:paid)
assert result.color == "badge-success"
assert result.icon == "hero-check-circle"
assert result.label == "Paid" || result.label == "Bezahlt"
end
test "returns badge component for unpaid status" do
result = MembershipFeeStatus.format_cycle_status_badge(:unpaid)
assert result.color == "badge-error"
assert result.icon == "hero-x-circle"
assert result.label == "Unpaid" || result.label == "Unbezahlt"
end
test "returns badge component for suspended status" do
result = MembershipFeeStatus.format_cycle_status_badge(:suspended)
assert result.color == "badge-ghost"
assert result.icon == "hero-pause-circle"
assert result.label == "Suspended" || result.label == "Ausgesetzt"
end
test "handles nil status gracefully" do
result = MembershipFeeStatus.format_cycle_status_badge(nil)
assert result == nil
end
end
describe "filter_members_by_cycle_status/3" do
test "filters paid members in last cycle" do
fee_type = create_fee_type(%{interval: :yearly})
today = Date.utc_today()
last_year_start = Date.new!(today.year - 1, 1, 1)
# Member with paid last cycle
member1 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member1, fee_type, %{cycle_start: last_year_start, status: :paid})
# Member with unpaid last cycle
member2 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{cycle_start: last_year_start, status: :unpaid})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
members =
[member1, member2]
|> Enum.map(fn m ->
m
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type, actor: system_actor)
end)
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :paid, false)
assert length(filtered) == 1
assert List.first(filtered).id == member1.id
end
test "filters unpaid members in last cycle" do
fee_type = create_fee_type(%{interval: :yearly})
today = Date.utc_today()
last_year_start = Date.new!(today.year - 1, 1, 1)
# Member with paid last cycle
member1 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member1, fee_type, %{cycle_start: last_year_start, status: :paid})
# Member with unpaid last cycle
member2 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{cycle_start: last_year_start, status: :unpaid})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
members =
[member1, member2]
|> Enum.map(fn m ->
m
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type, actor: system_actor)
end)
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :unpaid, false)
assert length(filtered) == 1
assert List.first(filtered).id == member2.id
end
test "filters paid members in current cycle" do
fee_type = create_fee_type(%{interval: :yearly})
today = Date.utc_today()
current_year_start = Date.new!(today.year, 1, 1)
# Member with paid current cycle
member1 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member1, fee_type, %{cycle_start: current_year_start, status: :paid})
# Member with unpaid current cycle
member2 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :unpaid})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
members =
[member1, member2]
|> Enum.map(fn m ->
m
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type, actor: system_actor)
end)
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :paid, true)
assert length(filtered) == 1
assert List.first(filtered).id == member1.id
end
test "filters unpaid members in current cycle" do
fee_type = create_fee_type(%{interval: :yearly})
today = Date.utc_today()
current_year_start = Date.new!(today.year, 1, 1)
# Member with paid current cycle
member1 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member1, fee_type, %{cycle_start: current_year_start, status: :paid})
# Member with unpaid current cycle
member2 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :unpaid})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
members =
[member1, member2]
|> Enum.map(fn m ->
m
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type, actor: system_actor)
end)
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :unpaid, true)
assert length(filtered) == 1
assert List.first(filtered).id == member2.id
end
end
end