Replace the create_fee_type/create_cycle helpers duplicated across 18/8 membership-fee test files with a single shared definition in Mv.Fixtures, reconciling the divergent local signatures (including the reversed argument order) into one superset so behavior is unchanged.
439 lines
14 KiB
Elixir
439 lines
14 KiB
Elixir
defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|
@moduledoc """
|
|
Tests for membership fees section in member detail view.
|
|
"""
|
|
use MvWeb.ConnCase, async: false
|
|
|
|
import Phoenix.LiveViewTest
|
|
import Mv.Fixtures, only: [create_fee_type: 1, create_cycle: 3]
|
|
|
|
alias Mv.MembershipFees.MembershipFeeCycle
|
|
|
|
require Ash.Query
|
|
|
|
describe "cycle-regeneration control tooltip (§3.5 icon/tooltip audit)" do
|
|
test "the regenerate_cycles control carries a tooltip and accessible label", %{conn: conn} do
|
|
fee_type = create_fee_type(%{interval: :yearly})
|
|
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
|
|
|
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
|
|
|
view
|
|
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
|
|> render_click()
|
|
|
|
assert has_element?(view, ".tooltip[data-tip] button[phx-click=regenerate_cycles]")
|
|
assert has_element?(view, "button[phx-click=regenerate_cycles][aria-label]")
|
|
end
|
|
end
|
|
|
|
describe "cycles table display" do
|
|
test "displays all cycles for member", %{conn: conn} do
|
|
fee_type = create_fee_type(%{interval: :yearly})
|
|
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
|
|
|
_cycle1 =
|
|
create_cycle(member, fee_type, %{
|
|
cycle_start: ~D[2022-01-01],
|
|
status: :paid,
|
|
replace_existing: true
|
|
})
|
|
|
|
_cycle2 =
|
|
create_cycle(member, fee_type, %{
|
|
cycle_start: ~D[2023-01-01],
|
|
status: :unpaid,
|
|
replace_existing: true
|
|
})
|
|
|
|
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
|
|
|
# Switch to membership fees tab
|
|
view
|
|
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
|
|> render_click()
|
|
|
|
html = render(view)
|
|
|
|
# Should show cycles table
|
|
assert html =~ "Membership Fees" || html =~ "Mitgliedsbeiträge"
|
|
# Check for formatted cycle dates (e.g., "01.01.2022" or "2022")
|
|
assert html =~ "2022" || html =~ "2023" || html =~ "01.01.2022" || html =~ "01.01.2023"
|
|
end
|
|
|
|
test "table columns show correct data", %{conn: conn} do
|
|
fee_type = create_fee_type(%{interval: :yearly, amount: Decimal.new("60.00")})
|
|
member = Mv.Fixtures.member_fixture(%{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,
|
|
replace_existing: true
|
|
})
|
|
|
|
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
|
|
|
# Switch to membership fees tab
|
|
view
|
|
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
|
|> render_click()
|
|
|
|
html = render(view)
|
|
|
|
# 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 display" do
|
|
test "shows assigned membership fee type", %{conn: conn} do
|
|
yearly_type = create_fee_type(%{interval: :yearly, name: "Yearly Type"})
|
|
_monthly_type = create_fee_type(%{interval: :monthly, name: "Monthly Type"})
|
|
|
|
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: yearly_type.id})
|
|
|
|
{:ok, _view, html} = live(conn, "/members/#{member.id}")
|
|
|
|
# Should show yearly type name
|
|
assert html =~ "Yearly Type"
|
|
end
|
|
|
|
test "shows no type message when no type assigned and Regenerate Cycles button is hidden", %{
|
|
conn: conn
|
|
} do
|
|
member = Mv.Fixtures.member_fixture(%{})
|
|
|
|
{:ok, view, html} = live(conn, "/members/#{member.id}")
|
|
|
|
# Should show message about no type assigned
|
|
assert html =~ "No membership fee type assigned" || html =~ "No type"
|
|
|
|
# Switch to membership fees tab: message and no Regenerate Cycles button
|
|
view
|
|
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
|
|> render_click()
|
|
|
|
refute has_element?(view, "button[phx-click='regenerate_cycles']"),
|
|
"Regenerate Cycles should be hidden when no membership fee type is assigned"
|
|
end
|
|
end
|
|
|
|
describe "status change actions" do
|
|
test "mark as paid works", %{conn: conn} do
|
|
fee_type = create_fee_type(%{interval: :yearly})
|
|
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
|
|
|
cycle =
|
|
create_cycle(member, fee_type, %{
|
|
cycle_start: ~D[2023-01-01],
|
|
status: :unpaid,
|
|
replace_existing: true
|
|
})
|
|
|
|
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
|
|
|
# Switch to membership fees tab
|
|
view
|
|
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
|
|> render_click()
|
|
|
|
# Mark as paid
|
|
view
|
|
|> element(
|
|
"button[phx-click='mark_cycle_status'][phx-value-cycle_id='#{cycle.id}'][phx-value-status='paid']"
|
|
)
|
|
|> render_click()
|
|
|
|
# Verify cycle is now paid
|
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
|
|
updated_cycle =
|
|
Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id),
|
|
actor: system_actor
|
|
)
|
|
|
|
assert updated_cycle.status == :paid
|
|
end
|
|
|
|
test "mark as suspended works", %{conn: conn} do
|
|
fee_type = create_fee_type(%{interval: :yearly})
|
|
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
|
|
|
cycle =
|
|
create_cycle(member, fee_type, %{
|
|
cycle_start: ~D[2023-01-01],
|
|
status: :unpaid,
|
|
replace_existing: true
|
|
})
|
|
|
|
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
|
|
|
# Switch to membership fees tab
|
|
view
|
|
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
|
|> render_click()
|
|
|
|
# Mark as suspended
|
|
view
|
|
|> element(
|
|
"button[phx-click='mark_cycle_status'][phx-value-cycle_id='#{cycle.id}'][phx-value-status='suspended']"
|
|
)
|
|
|> render_click()
|
|
|
|
# Verify cycle is now suspended
|
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
|
|
updated_cycle =
|
|
Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id),
|
|
actor: system_actor
|
|
)
|
|
|
|
assert updated_cycle.status == :suspended
|
|
end
|
|
|
|
test "mark as unpaid works", %{conn: conn} do
|
|
fee_type = create_fee_type(%{interval: :yearly})
|
|
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
|
|
|
cycle =
|
|
create_cycle(member, fee_type, %{
|
|
cycle_start: ~D[2023-01-01],
|
|
status: :paid,
|
|
replace_existing: true
|
|
})
|
|
|
|
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
|
|
|
# Switch to membership fees tab
|
|
view
|
|
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
|
|> render_click()
|
|
|
|
# Mark as unpaid
|
|
view
|
|
|> element(
|
|
"button[phx-click='mark_cycle_status'][phx-value-cycle_id='#{cycle.id}'][phx-value-status='unpaid']"
|
|
)
|
|
|> render_click()
|
|
|
|
# Verify cycle is now unpaid
|
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
|
|
updated_cycle =
|
|
Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id),
|
|
actor: system_actor
|
|
)
|
|
|
|
assert updated_cycle.status == :unpaid
|
|
end
|
|
end
|
|
|
|
describe "cycle regeneration" do
|
|
test "manual regeneration button exists and can be clicked", %{conn: conn} do
|
|
fee_type = create_fee_type(%{interval: :yearly})
|
|
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
|
|
|
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
|
|
|
# Switch to membership fees tab
|
|
view
|
|
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
|
|> render_click()
|
|
|
|
# Verify regenerate button exists
|
|
assert has_element?(view, "button[phx-click='regenerate_cycles']")
|
|
|
|
# Trigger regeneration (just verify it doesn't crash)
|
|
view
|
|
|> element("button[phx-click='regenerate_cycles']")
|
|
|> render_click()
|
|
|
|
# Verify the action completed without error
|
|
# (The actual cycle generation depends on many factors, so we just test the UI works)
|
|
assert render(view) =~ "Membership Fees" || render(view) =~ "Mitgliedsbeiträge"
|
|
end
|
|
end
|
|
|
|
describe "edge cases" do
|
|
test "handles members without membership fee type gracefully", %{conn: conn} do
|
|
# No fee type
|
|
member = Mv.Fixtures.member_fixture(%{})
|
|
|
|
{:ok, _view, html} = live(conn, "/members/#{member.id}")
|
|
|
|
# Should not crash
|
|
assert html =~ member.first_name
|
|
end
|
|
|
|
test "create_cycle with an unparseable date shows an error instead of crashing", %{conn: conn} do
|
|
fee_type = create_fee_type(%{interval: :yearly})
|
|
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
|
|
|
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
|
|
|
view
|
|
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
|
|> render_click()
|
|
|
|
view
|
|
|> element("button[phx-click='open_create_cycle_modal']")
|
|
|> render_click()
|
|
|
|
html =
|
|
view
|
|
|> element("form[phx-submit='create_cycle']")
|
|
|> render_submit(%{"date" => "not-a-date", "amount" => "10"})
|
|
|
|
assert html =~ "Invalid date format"
|
|
end
|
|
end
|
|
|
|
describe "read_only user (Vorstand/Buchhaltung) - no cycle action buttons" do
|
|
@tag role: :read_only
|
|
test "read_only does not see Regenerate Cycles, Delete All Cycles, or Create Cycle buttons",
|
|
%{
|
|
conn: conn
|
|
} do
|
|
fee_type = create_fee_type(%{interval: :yearly})
|
|
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
|
|
|
_cycle =
|
|
create_cycle(member, fee_type, %{
|
|
cycle_start: ~D[2023-01-01],
|
|
status: :unpaid,
|
|
replace_existing: true
|
|
})
|
|
|
|
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
|
|
|
view
|
|
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
|
|> render_click()
|
|
|
|
refute has_element?(view, "button[phx-click='regenerate_cycles']")
|
|
refute has_element?(view, "button[phx-click='delete_all_cycles']")
|
|
refute has_element?(view, "button[phx-click='open_create_cycle_modal']")
|
|
end
|
|
|
|
@tag role: :read_only
|
|
test "read_only does not see Paid, Unpaid, Suspended, or Delete buttons in cycles table", %{
|
|
conn: conn
|
|
} do
|
|
fee_type = create_fee_type(%{interval: :yearly})
|
|
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
|
|
|
cycle =
|
|
create_cycle(member, fee_type, %{
|
|
cycle_start: ~D[2023-01-01],
|
|
status: :unpaid,
|
|
replace_existing: true
|
|
})
|
|
|
|
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
|
|
|
view
|
|
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
|
|> render_click()
|
|
|
|
# Row action buttons must not be present for read_only
|
|
refute has_element?(view, "button[phx-click='mark_cycle_status']")
|
|
refute has_element?(view, "button[phx-click='delete_cycle']")
|
|
# Sanity: cycle row is present (read is allowed)
|
|
assert has_element?(view, "tr[id='cycle-#{cycle.id}']")
|
|
end
|
|
end
|
|
|
|
describe "read_only cannot delete all cycles (policy enforced via Ash.destroy)" do
|
|
@tag role: :read_only
|
|
test "Ash.destroy returns Forbidden for read_only so handler would reject", %{
|
|
current_user: read_only_user
|
|
} do
|
|
# The handler uses Ash.destroy per cycle, so if the handler were triggered
|
|
# (e.g. via dev tools), the server would enforce policy and show an error.
|
|
# This test verifies that Ash.destroy(cycle, actor: read_only_user) returns Forbidden.
|
|
fee_type = create_fee_type(%{interval: :yearly})
|
|
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
|
|
|
cycle =
|
|
create_cycle(member, fee_type, %{
|
|
cycle_start: ~D[2023-01-01],
|
|
status: :unpaid,
|
|
replace_existing: true
|
|
})
|
|
|
|
assert {:error, %Ash.Error.Forbidden{}} =
|
|
Ash.destroy(cycle, domain: Mv.MembershipFees, actor: read_only_user)
|
|
end
|
|
end
|
|
|
|
describe "read_only cannot trigger regenerate_cycles (handler enforces can?)" do
|
|
@tag role: :read_only
|
|
test "read_only cannot create MembershipFeeCycle so regenerate_cycles handler would show flash error",
|
|
%{current_user: read_only_user} do
|
|
# The regenerate_cycles handler checks can?(actor, :create, MembershipFeeCycle) before
|
|
# calling the generator. If a read_only user triggered the event (e.g. via DevTools),
|
|
# the handler returns flash error and no new cycles are created.
|
|
# This test verifies the condition the handler uses.
|
|
refute MvWeb.Authorization.can?(read_only_user, :create, MembershipFeeCycle),
|
|
"read_only must not be allowed to create MembershipFeeCycle so handler rejects regenerate_cycles"
|
|
end
|
|
end
|
|
|
|
describe "confirm_delete_all_cycles handler (policy enforced)" do
|
|
@tag role: :admin
|
|
test "admin can delete all cycles via UI and cycles are removed", %{conn: conn} do
|
|
# Use English locale so confirmation "Yes" matches gettext("Yes")
|
|
conn = put_session(conn, :locale, "en")
|
|
|
|
fee_type = create_fee_type(%{interval: :yearly})
|
|
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
|
|
|
_c1 =
|
|
create_cycle(member, fee_type, %{
|
|
cycle_start: ~D[2022-01-01],
|
|
status: :paid,
|
|
replace_existing: true
|
|
})
|
|
|
|
_c2 =
|
|
create_cycle(member, fee_type, %{
|
|
cycle_start: ~D[2023-01-01],
|
|
status: :unpaid,
|
|
replace_existing: true
|
|
})
|
|
|
|
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
|
|
|
view
|
|
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
|
|> render_click()
|
|
|
|
view
|
|
|> element("button[phx-click='delete_all_cycles']")
|
|
|> render_click()
|
|
|
|
view
|
|
|> element("input[phx-keyup='update_delete_all_confirmation']")
|
|
|> render_keyup(%{"value" => "Yes"})
|
|
|
|
view
|
|
|> element("button[phx-click='confirm_delete_all_cycles']")
|
|
|> render_click()
|
|
|
|
_html = render(view)
|
|
|
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
|
|
remaining =
|
|
Mv.MembershipFees.MembershipFeeCycle
|
|
|> Ash.Query.filter(member_id == ^member.id)
|
|
|> Ash.read!(actor: system_actor)
|
|
|
|
assert remaining == [],
|
|
"Expected all cycles to be deleted (handler enforces policy via Ash.destroy)"
|
|
end
|
|
end
|
|
end
|