test: add comprehensive tests for membership fee UI components

Add tests for all membership fee UI components following TDD principles:
This commit is contained in:
Moritz 2025-12-16 11:13:09 +01:00
parent bc989422e2
commit 5789079ab0
Signed by: moritz
GPG key ID: 1020A035E5DD0824
9 changed files with 1695 additions and 0 deletions

View file

@ -0,0 +1,167 @@
defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
@moduledoc """
Tests for membership fee type dropdown in member form.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Membership.Member
alias Mv.MembershipFees.MembershipFeeType
require Ash.Query
setup do
# Create admin user
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
conn = log_in_user(build_conn(), user)
%{conn: conn, user: user}
end
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
# Helper to create a member
defp create_member(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
end
describe "membership fee type dropdown" do
test "displays in form", %{conn: conn} do
{:ok, view, html} = live(conn, "/members/new")
# Should show membership fee type dropdown
assert html =~ "membership_fee_type_id" || html =~ "Membership Fee Type" ||
html =~ "Beitragsart"
end
test "shows available types", %{conn: conn} do
fee_type1 = create_fee_type(%{name: "Type 1", interval: :yearly})
fee_type2 = create_fee_type(%{name: "Type 2", interval: :yearly})
{:ok, view, html} = live(conn, "/members/new")
assert html =~ "Type 1"
assert html =~ "Type 2"
end
test "filters to same interval types if member has type", %{conn: conn} do
yearly_type = create_fee_type(%{name: "Yearly Type", interval: :yearly})
monthly_type = create_fee_type(%{name: "Monthly Type", interval: :monthly})
member = create_member(%{membership_fee_type_id: yearly_type.id})
{:ok, view, html} = live(conn, "/members/#{member.id}/edit")
# Should show yearly type but not monthly
assert html =~ "Yearly Type"
refute html =~ "Monthly Type"
end
test "shows warning if different interval selected", %{conn: conn} do
yearly_type = create_fee_type(%{name: "Yearly Type", interval: :yearly})
monthly_type = create_fee_type(%{name: "Monthly Type", interval: :monthly})
member = create_member(%{membership_fee_type_id: yearly_type.id})
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
# Try to select monthly type (should show warning)
html =
view
|> form("form", %{"member[membership_fee_type_id]" => monthly_type.id})
|> render_change()
assert html =~ "Warning" || html =~ "Warnung" || html =~ "not allowed"
end
test "warning cleared if same interval selected", %{conn: conn} do
yearly_type1 = create_fee_type(%{name: "Yearly Type 1", interval: :yearly})
yearly_type2 = create_fee_type(%{name: "Yearly Type 2", interval: :yearly})
member = create_member(%{membership_fee_type_id: yearly_type1.id})
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
# Select another yearly type (should not show warning)
html =
view
|> form("form", %{"member[membership_fee_type_id]" => yearly_type2.id})
|> render_change()
refute html =~ "Warning" || html =~ "Warnung"
end
test "form saves with selected membership fee type", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
{:ok, view, _html} = live(conn, "/members/new")
form_data = %{
"member[first_name]" => "Test",
"member[last_name]" => "Member",
"member[email]" => "test#{System.unique_integer([:positive])}@example.com",
"member[membership_fee_type_id]" => fee_type.id
}
{:error, {:live_redirect, %{to: _to}}} =
view
|> form("form", form_data)
|> render_submit()
# Verify member was created with fee type
member =
Member
|> Ash.Query.filter(email == ^form_data["member[email]"])
|> Ash.read_one!()
assert member.membership_fee_type_id == fee_type.id
end
test "new members get default membership fee type", %{conn: conn} do
# Set default fee type in settings
fee_type = create_fee_type(%{interval: :yearly})
Mv.Membership.Setting
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
default_membership_fee_type_id: fee_type.id
})
|> Ash.update!()
{:ok, view, _html} = live(conn, "/members/new")
# Form should have default fee type selected
html = render(view)
assert html =~ fee_type.name || html =~ "selected"
end
end
end

View file

@ -0,0 +1,153 @@
defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
@moduledoc """
Tests for MembershipFeeStatus helper module.
"""
use Mv.DataCase, async: true
alias MvWeb.MemberLive.Index.MembershipFeeStatus
alias Mv.Membership.Member
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
require Ash.Query
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
# Helper to create a member
defp create_member(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
end
# Helper to create a cycle
defp create_cycle(member, fee_type, attrs) do
default_attrs = %{
cycle_start: ~D[2023-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :unpaid
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
describe "load_cycles_for_members/2" do
test "efficiently loads cycles for members" do
fee_type = create_fee_type(%{interval: :yearly})
member1 = create_member(%{membership_fee_type_id: fee_type.id})
member2 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member1, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
create_cycle(member2, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
query =
Member
|> Ash.Query.filter(id in [^member1.id, ^member2.id])
|> MembershipFeeStatus.load_cycles_for_members()
members = Ash.read!(query)
assert length(members) == 2
# Verify cycles are loaded
member1_loaded = Enum.find(members, &(&1.id == member1.id))
member2_loaded = Enum.find(members, &(&1.id == member2.id))
assert member1_loaded.membership_fee_cycles != nil
assert member2_loaded.membership_fee_cycles != nil
end
end
describe "get_cycle_status_for_member/2" do
test "returns status of last completed cycle" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
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})
today = ~D[2024-06-15]
status = MembershipFeeStatus.get_cycle_status_for_member(member, today, false)
# Should return status of 2023 cycle (last completed)
assert status == :unpaid
end
test "returns status of current cycle when show_current is true" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
create_cycle(member, fee_type, %{cycle_start: ~D[2024-01-01], status: :suspended})
today = ~D[2024-06-15]
status = MembershipFeeStatus.get_cycle_status_for_member(member, today, true)
# Should return status of 2024 cycle (current)
assert status == :suspended
end
test "returns nil if no cycles exist" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
today = Date.utc_today()
status = MembershipFeeStatus.get_cycle_status_for_member(member, today, 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 =~ "text-success"
assert result =~ "hero-check-circle"
end
test "returns badge component for unpaid status" do
result = MembershipFeeStatus.format_cycle_status_badge(:unpaid)
assert result =~ "text-error"
assert result =~ "hero-x-circle"
end
test "returns badge component for suspended status" do
result = MembershipFeeStatus.format_cycle_status_badge(:suspended)
assert result =~ "text-base-content/60"
assert result =~ "hero-pause-circle"
end
test "handles nil status gracefully" do
result = MembershipFeeStatus.format_cycle_status_badge(nil)
assert result =~ "text-base-content/60"
end
end
end

View file

@ -0,0 +1,226 @@
defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
@moduledoc """
Tests for membership fee status column in member list view.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Membership.Member
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
require Ash.Query
setup do
# Create admin user
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
conn = log_in_user(build_conn(), user)
%{conn: conn, user: user}
end
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
# Helper to create a member
defp create_member(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
end
# Helper to create a cycle
defp create_cycle(member, fee_type, attrs) do
default_attrs = %{
cycle_start: ~D[2023-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :unpaid
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
describe "status column display" do
test "shows status column in member list", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
{:ok, _view, html} = live(conn, "/members")
# Should show membership fee status column
assert html =~ "Membership Fee Status" || html =~ "Mitgliedsbeitrag Status"
end
test "shows last completed cycle status by default", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
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})
{:ok, view, _html} = live(conn, "/members")
# Should show unpaid status (2023 is last completed)
html = render(view)
assert html =~ "hero-x-circle" || html =~ "unpaid"
end
test "toggle switches to current cycle view", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
today = Date.utc_today()
current_year_start = %{today | month: 1, day: 1}
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
create_cycle(member, fee_type, %{cycle_start: current_year_start, status: :suspended})
{:ok, view, _html} = live(conn, "/members")
# Toggle to current cycle
view
|> element("button[phx-click='toggle_current_cycle']")
|> render_click()
html = render(view)
# Should show suspended status (current cycle)
assert html =~ "hero-pause-circle" || html =~ "suspended"
end
test "shows correct color coding for paid status", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
{:ok, view, _html} = live(conn, "/members")
html = render(view)
assert html =~ "text-success" || html =~ "hero-check-circle"
end
test "shows correct color coding for unpaid status", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
{:ok, view, _html} = live(conn, "/members")
html = render(view)
assert html =~ "text-error" || html =~ "hero-x-circle"
end
test "shows correct color coding for suspended status", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :suspended})
{:ok, view, _html} = live(conn, "/members")
html = render(view)
assert html =~ "text-base-content/60" || html =~ "hero-pause-circle"
end
test "handles members without cycles gracefully", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
# No cycles created
{:ok, view, _html} = live(conn, "/members")
html = render(view)
# Should not crash, may show empty or default state
assert html =~ member.first_name
end
end
describe "filters" do
test "filter unpaid in last cycle works", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
# Member with unpaid last cycle
member1 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member1, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
# Member with paid last cycle
member2 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
{:ok, view, _html} = live(conn, "/members?membership_fee_status_filter=unpaid_last")
html = render(view)
assert html =~ member1.first_name
refute html =~ member2.first_name
end
test "filter unpaid in current cycle works", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
today = Date.utc_today()
current_year_start = %{today | month: 1, day: 1}
# Member with unpaid current cycle
member1 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member1, fee_type, %{cycle_start: current_year_start, status: :unpaid})
# Member with paid current cycle
member2 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :paid})
{:ok, view, _html} = live(conn, "/members?membership_fee_status_filter=unpaid_current")
html = render(view)
assert html =~ member1.first_name
refute html =~ member2.first_name
end
end
describe "performance" do
test "loads cycles efficiently without N+1 queries", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
# Create multiple members with cycles
Enum.each(1..5, fn _ ->
member = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
end)
{:ok, _view, html} = live(conn, "/members")
# Should render without errors (N+1 would cause performance issues)
assert html =~ "Members" || html =~ "Mitglieder"
end
end
end

View file

@ -0,0 +1,221 @@
defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
@moduledoc """
Integration tests for membership fee UI workflows.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Membership.Member
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
require Ash.Query
setup do
# Create admin user
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
conn = log_in_user(build_conn(), user)
%{conn: conn, user: user}
end
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
# Helper to create a member
defp create_member(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
end
describe "end-to-end workflows" do
test "create type → assign to member → view cycles → change status", %{conn: conn} do
# Create type
fee_type = create_fee_type(%{name: "Regular", interval: :yearly})
# Assign to member
member = create_member(%{membership_fee_type_id: fee_type.id})
# View cycles
{:ok, view, html} = live(conn, "/members/#{member.id}")
assert html =~ "Membership Fees" || html =~ "Mitgliedsbeiträge"
# Get a cycle
cycles =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
if length(cycles) > 0 do
cycle = List.first(cycles)
# Change status
view
|> element("button[phx-click='mark_as_paid'][phx-value-cycle-id='#{cycle.id}']")
|> render_click()
# Verify status changed
updated_cycle = Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id))
assert updated_cycle.status == :paid
end
end
test "change member type → cycles regenerate", %{conn: conn} do
fee_type1 =
create_fee_type(%{name: "Type 1", interval: :yearly, amount: Decimal.new("50.00")})
fee_type2 =
create_fee_type(%{name: "Type 2", interval: :yearly, amount: Decimal.new("75.00")})
member = create_member(%{membership_fee_type_id: fee_type1.id})
# Change type
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
view
|> form("form", %{"member[membership_fee_type_id]" => fee_type2.id})
|> render_submit()
# Verify cycles regenerated with new amount
cycles =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.Query.filter(status == :unpaid)
|> Ash.read!()
# Future unpaid cycles should have new amount
Enum.each(cycles, fn cycle ->
if Date.compare(cycle.cycle_start, Date.utc_today()) != :lt do
assert Decimal.equal?(cycle.amount, fee_type2.amount)
end
end)
end
test "update settings → new members get default type", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
# Update settings
Mv.Membership.Setting
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
default_membership_fee_type_id: fee_type.id
})
|> Ash.update!()
# Create new member
{:ok, view, _html} = live(conn, "/members/new")
form_data = %{
"member[first_name]" => "New",
"member[last_name]" => "Member",
"member[email]" => "new#{System.unique_integer([:positive])}@example.com"
}
{:error, {:live_redirect, %{to: _to}}} =
view
|> form("form", form_data)
|> render_submit()
# Verify member got default type
member =
Member
|> Ash.Query.filter(email == ^form_data["member[email]"])
|> Ash.read_one!()
assert member.membership_fee_type_id == fee_type.id
end
test "delete cycle → confirmation → cycle deleted", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle =
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, %{
cycle_start: ~D[2023-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :unpaid
})
|> Ash.create!()
{:ok, view, _html} = live(conn, "/members/#{member.id}")
# Delete cycle with confirmation
view
|> element("button[phx-click='delete_cycle'][phx-value-cycle-id='#{cycle.id}']")
|> render_click()
# Confirm deletion
view
|> element("button[phx-click='confirm_delete_cycle'][phx-value-cycle-id='#{cycle.id}']")
|> render_click()
# Verify cycle deleted
assert_raise Ash.Error.Query.NotFound, fn ->
Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id))
end
end
test "edit cycle amount → modal → amount updated", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle =
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, %{
cycle_start: ~D[2023-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :unpaid
})
|> Ash.create!()
{:ok, view, _html} = live(conn, "/members/#{member.id}")
# Open edit modal
view
|> element("button[phx-click='edit_cycle_amount'][phx-value-cycle-id='#{cycle.id}']")
|> render_click()
# Update amount
view
|> form("form", %{"amount" => "75.00"})
|> render_submit()
# Verify amount updated
updated_cycle = Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id))
assert updated_cycle.amount == Decimal.new("75.00")
end
end
end

View file

@ -0,0 +1,232 @@
defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
@moduledoc """
Tests for membership fees section in member detail view.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Membership.Member
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
require Ash.Query
setup do
# Create admin user
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
conn = log_in_user(build_conn(), user)
%{conn: conn, user: user}
end
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
# Helper to create a member
defp create_member(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
end
# Helper to create a cycle
defp create_cycle(member, fee_type, attrs) do
default_attrs = %{
cycle_start: ~D[2023-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :unpaid
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
describe "cycles table display" do
test "displays all cycles for member", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
_cycle1 = create_cycle(member, fee_type, %{cycle_start: ~D[2022-01-01], status: :paid})
_cycle2 = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
{:ok, _view, html} = live(conn, "/members/#{member.id}")
# Should show cycles table
assert html =~ "Membership Fees" || html =~ "Mitgliedsbeiträge"
assert html =~ "2022" || html =~ "2023"
end
test "table columns show correct data", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly, amount: Decimal.new("60.00")})
member = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member, fee_type, %{
cycle_start: ~D[2023-01-01],
amount: Decimal.new("60.00"),
status: :paid
})
{:ok, _view, html} = live(conn, "/members/#{member.id}")
# Should show interval, amount, status
assert html =~ "Yearly" || html =~ "Jährlich"
assert html =~ "60" || html =~ "60,00"
assert html =~ "paid" || html =~ "bezahlt"
end
end
describe "membership fee type dropdown" do
test "shows only same-interval types", %{conn: conn} do
yearly_type = create_fee_type(%{interval: :yearly, name: "Yearly Type"})
_monthly_type = create_fee_type(%{interval: :monthly, name: "Monthly Type"})
member = create_member(%{membership_fee_type_id: yearly_type.id})
{:ok, _view, html} = live(conn, "/members/#{member.id}")
# Should show yearly type but not monthly
assert html =~ "Yearly Type"
refute html =~ "Monthly Type"
end
test "shows warning if different interval selected", %{conn: conn} do
yearly_type = create_fee_type(%{interval: :yearly, name: "Yearly Type"})
monthly_type = create_fee_type(%{interval: :monthly, name: "Monthly Type"})
member = create_member(%{membership_fee_type_id: yearly_type.id})
{:ok, view, _html} = live(conn, "/members/#{member.id}")
# Try to select monthly type (should show warning)
# Note: This test may need adjustment based on actual implementation
html =
view
|> form("form", %{"membership_fee_type_id" => monthly_type.id})
|> render_change()
assert html =~ "Warning" || html =~ "Warnung" || html =~ "not allowed"
end
end
describe "status change actions" do
test "mark as paid works", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
{:ok, view, _html} = live(conn, "/members/#{member.id}")
# Mark as paid
view
|> element("button[phx-click='mark_as_paid'][phx-value-cycle-id='#{cycle.id}']")
|> render_click()
# Verify cycle is now paid
updated_cycle = Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id))
assert updated_cycle.status == :paid
end
test "mark as suspended works", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
{:ok, view, _html} = live(conn, "/members/#{member.id}")
# Mark as suspended
view
|> element("button[phx-click='mark_as_suspended'][phx-value-cycle-id='#{cycle.id}']")
|> render_click()
# Verify cycle is now suspended
updated_cycle = Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id))
assert updated_cycle.status == :suspended
end
test "mark as unpaid works", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
{:ok, view, _html} = live(conn, "/members/#{member.id}")
# Mark as unpaid
view
|> element("button[phx-click='mark_as_unpaid'][phx-value-cycle-id='#{cycle.id}']")
|> render_click()
# Verify cycle is now unpaid
updated_cycle = Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id))
assert updated_cycle.status == :unpaid
end
end
describe "cycle regeneration" do
test "manual regeneration works", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
{:ok, view, _html} = live(conn, "/members/#{member.id}")
# Trigger regeneration
view
|> element("button[phx-click='regenerate_cycles']")
|> render_click()
# Should have cycles generated
cycles =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
assert length(cycles) > 0
end
end
describe "edge cases" do
test "handles members without membership fee type gracefully", %{conn: conn} do
# No fee type
member = create_member(%{})
{:ok, _view, html} = live(conn, "/members/#{member.id}")
# Should not crash
assert html =~ member.first_name
end
end
end