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.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeCycle require Ash.Query # Helper to create a membership fee type defp create_fee_type(attrs) do system_actor = Mv.Helpers.SystemActor.get_system_actor() 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!(actor: system_actor) end # Helper to create a cycle defp create_cycle(member, fee_type, attrs) do system_actor = Mv.Helpers.SystemActor.get_system_actor() # Delete any auto-generated cycles first to avoid conflicts existing_cycles = MembershipFeeCycle |> Ash.Query.filter(member_id == ^member.id) |> Ash.read!(actor: system_actor) Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle, actor: system_actor) end) 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!(actor: system_actor) 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}) _cycle2 = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid}) {: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 }) {: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}) {: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}) {: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}) {: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 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}) {: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}) {: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}) 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}) _c2 = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid}) {: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