Merge remote-tracking branch 'origin/main' into feature/member-overview-groups
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing

This commit is contained in:
Simon 2026-02-16 15:57:57 +01:00
commit 6831ba046f
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
48 changed files with 3516 additions and 182 deletions

View file

@ -0,0 +1 @@

View file

@ -0,0 +1 @@

227
test/mv/statistics_test.exs Normal file
View file

@ -0,0 +1,227 @@
defmodule Mv.StatisticsTest do
@moduledoc """
Tests for Mv.Statistics module (member and membership fee cycle statistics).
"""
use Mv.DataCase, async: true
require Ash.Query
import Ash.Expr
alias Mv.Membership.Member
alias Mv.Statistics
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType
setup do
actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: actor}
end
defp create_fee_type(actor, attrs) do
MembershipFeeType
|> Ash.Changeset.for_create(
:create,
Map.merge(
%{
name: "Test Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
},
attrs
)
)
|> Ash.create!(actor: actor)
end
describe "first_join_year/1" do
test "returns the year of the earliest join_date", %{actor: actor} do
Mv.Fixtures.member_fixture(%{join_date: ~D[2019-03-15]})
Mv.Fixtures.member_fixture(%{join_date: ~D[2022-01-01]})
assert Statistics.first_join_year(actor: actor) == 2019
end
test "returns the only member's join year when one member exists", %{actor: actor} do
Mv.Fixtures.member_fixture(%{join_date: ~D[2021-06-01]})
assert Statistics.first_join_year(actor: actor) == 2021
end
test "returns nil when no members exist", %{actor: actor} do
# Guarantee empty member table so the assertion is deterministic
Member
|> Ash.read!(actor: actor)
|> Enum.each(&Ash.destroy!(&1, actor: actor))
result = Statistics.first_join_year(actor: actor)
assert is_nil(result)
end
end
describe "active_member_count/1" do
test "returns 0 when there are no members", %{actor: actor} do
assert Statistics.active_member_count(actor: actor) == 0
end
test "returns 1 when one member has no exit_date", %{actor: actor} do
Mv.Fixtures.member_fixture(%{join_date: ~D[2023-01-15]})
assert Statistics.active_member_count(actor: actor) == 1
end
test "returns 0 for that member when exit_date is set", %{actor: actor} do
_member =
Mv.Fixtures.member_fixture(%{join_date: ~D[2023-01-15], exit_date: ~D[2024-06-01]})
assert Statistics.active_member_count(actor: actor) == 0
end
test "counts only active members when mix of active and inactive", %{actor: actor} do
Mv.Fixtures.member_fixture(%{join_date: ~D[2022-01-01]})
Mv.Fixtures.member_fixture(%{join_date: ~D[2023-01-01], exit_date: ~D[2024-01-01]})
assert Statistics.active_member_count(actor: actor) == 1
end
end
describe "inactive_member_count/1" do
test "returns 0 when all members are active", %{actor: actor} do
Mv.Fixtures.member_fixture(%{join_date: ~D[2023-01-01]})
assert Statistics.inactive_member_count(actor: actor) == 0
end
test "returns 1 when one member has exit_date set", %{actor: actor} do
Mv.Fixtures.member_fixture(%{join_date: ~D[2022-01-01], exit_date: ~D[2024-06-01]})
assert Statistics.inactive_member_count(actor: actor) == 1
end
end
describe "joins_by_year/2" do
test "returns 0 for year with no joins", %{actor: actor} do
assert Statistics.joins_by_year(1999, actor: actor) == 0
end
test "returns 1 when one member has join_date in that year", %{actor: actor} do
Mv.Fixtures.member_fixture(%{join_date: ~D[2023-06-15]})
assert Statistics.joins_by_year(2023, actor: actor) == 1
end
test "returns 2 when two members joined in that year", %{actor: actor} do
Mv.Fixtures.member_fixture(%{join_date: ~D[2023-01-01]})
Mv.Fixtures.member_fixture(%{join_date: ~D[2023-12-31]})
assert Statistics.joins_by_year(2023, actor: actor) == 2
end
end
describe "exits_by_year/2" do
test "returns 0 for year with no exits", %{actor: actor} do
assert Statistics.exits_by_year(1999, actor: actor) == 0
end
test "returns 1 when one member has exit_date in that year", %{actor: actor} do
Mv.Fixtures.member_fixture(%{join_date: ~D[2020-01-01], exit_date: ~D[2023-06-15]})
assert Statistics.exits_by_year(2023, actor: actor) == 1
end
end
describe "cycle_totals_by_year/2" do
test "returns zero totals for year with no cycles", %{actor: actor} do
result = Statistics.cycle_totals_by_year(1999, actor: actor)
assert result.total == Decimal.new(0)
assert result.paid == Decimal.new(0)
assert result.unpaid == Decimal.new(0)
assert result.suspended == Decimal.new(0)
end
test "returns totals by status for cycles in that year", %{actor: actor} do
fee_type = create_fee_type(actor, %{amount: Decimal.new("50.00")})
# Creating members with fee type triggers cycle generation (2020..today). We use 2024 cycles.
_member1 =
Mv.Fixtures.member_fixture(%{
join_date: ~D[2020-01-01],
membership_fee_type_id: fee_type.id
})
_member2 =
Mv.Fixtures.member_fixture(%{
join_date: ~D[2020-01-01],
membership_fee_type_id: fee_type.id
})
# Get 2024 cycles and set status (each member has one 2024 yearly cycle from generator)
cycles_2024 =
MembershipFeeCycle
|> Ash.Query.filter(
expr(cycle_start >= ^~D[2024-01-01] and cycle_start < ^~D[2025-01-01])
)
|> Ash.read!(actor: actor)
|> Enum.sort_by(& &1.member_id)
[c1, c2] = cycles_2024
assert {:ok, _} = Ash.update(c1, %{status: :paid}, domain: MembershipFees, actor: actor)
assert {:ok, _} =
Ash.update(c2, %{status: :suspended}, domain: MembershipFees, actor: actor)
result = Statistics.cycle_totals_by_year(2024, actor: actor)
assert Decimal.equal?(result.total, Decimal.new("100.00"))
assert Decimal.equal?(result.paid, Decimal.new("50.00"))
assert Decimal.equal?(result.unpaid, Decimal.new(0))
assert Decimal.equal?(result.suspended, Decimal.new("50.00"))
end
test "when fee_type_id is passed in opts, returns only cycles of that fee type", %{
actor: actor
} do
fee_type_a = create_fee_type(actor, %{amount: Decimal.new("30.00")})
fee_type_b = create_fee_type(actor, %{amount: Decimal.new("70.00")})
_m1 =
Mv.Fixtures.member_fixture(%{
join_date: ~D[2020-01-01],
membership_fee_type_id: fee_type_a.id
})
_m2 =
Mv.Fixtures.member_fixture(%{
join_date: ~D[2020-01-01],
membership_fee_type_id: fee_type_b.id
})
# Without filter: both fee types' cycles (2024)
all_result = Statistics.cycle_totals_by_year(2024, actor: actor)
assert Decimal.equal?(all_result.total, Decimal.new("100.00"))
# With fee_type_id as string (as from form/URL): only that type's cycles
opts_a = [actor: actor, fee_type_id: to_string(fee_type_a.id)]
result_a = Statistics.cycle_totals_by_year(2024, opts_a)
assert Decimal.equal?(result_a.total, Decimal.new("30.00"))
opts_b = [actor: actor, fee_type_id: to_string(fee_type_b.id)]
result_b = Statistics.cycle_totals_by_year(2024, opts_b)
assert Decimal.equal?(result_b.total, Decimal.new("70.00"))
end
end
describe "open_amount_total/1" do
test "returns 0 when there are no unpaid cycles", %{actor: actor} do
assert Statistics.open_amount_total(actor: actor) == Decimal.new(0)
end
test "returns sum of amount for all unpaid cycles", %{actor: actor} do
fee_type = create_fee_type(actor, %{amount: Decimal.new("50.00")})
_member =
Mv.Fixtures.member_fixture(%{
join_date: ~D[2020-01-01],
membership_fee_type_id: fee_type.id
})
# Cycle generator creates yearly cycles (2020..today), all unpaid by default
unpaid_sum = Statistics.open_amount_total(actor: actor)
assert Decimal.compare(unpaid_sum, Decimal.new(0)) == :gt
# Should be 50 * number of years from 2020 to current year
current_year = Date.utc_today().year
expected_count = current_year - 2020 + 1
assert Decimal.equal?(unpaid_sum, Decimal.new(50 * expected_count))
end
end
end

View file

@ -25,12 +25,13 @@ defmodule MvWeb.SidebarAuthorizationTest do
end
describe "sidebar menu with admin user" do
test "shows Members, Fee Types and Administration with all subitems" do
test "shows Members, Fee Types, Statistics and Administration with all subitems" do
user = Fixtures.user_with_role_fixture("admin")
html = render_sidebar(sidebar_assigns(user))
assert html =~ ~s(href="/members")
assert html =~ ~s(href="/membership_fee_types")
assert html =~ ~s(href="/statistics")
assert html =~ ~s(data-testid="sidebar-administration")
assert html =~ ~s(href="/users")
assert html =~ ~s(href="/groups")
@ -41,11 +42,12 @@ defmodule MvWeb.SidebarAuthorizationTest do
end
describe "sidebar menu with read_only user (Vorstand/Buchhaltung)" do
test "shows Members and Groups (from Administration)" do
test "shows Members, Statistics and Groups (from Administration)" do
user = Fixtures.user_with_role_fixture("read_only")
html = render_sidebar(sidebar_assigns(user))
assert html =~ ~s(href="/members")
assert html =~ ~s(href="/statistics")
assert html =~ ~s(href="/groups")
end
@ -61,11 +63,12 @@ defmodule MvWeb.SidebarAuthorizationTest do
end
describe "sidebar menu with normal_user (Kassenwart)" do
test "shows Members and Groups" do
test "shows Members, Statistics and Groups" do
user = Fixtures.user_with_role_fixture("normal_user")
html = render_sidebar(sidebar_assigns(user))
assert html =~ ~s(href="/members")
assert html =~ ~s(href="/statistics")
assert html =~ ~s(href="/groups")
end
@ -88,10 +91,11 @@ defmodule MvWeb.SidebarAuthorizationTest do
refute html =~ ~s(href="/members")
end
test "does not show Fee Types or Administration" do
test "does not show Statistics, Fee Types or Administration" do
user = Fixtures.user_with_role_fixture("own_data")
html = render_sidebar(sidebar_assigns(user))
refute html =~ ~s(href="/statistics")
refute html =~ ~s(href="/membership_fee_types")
refute html =~ ~s(href="/users")
refute html =~ ~s(data-testid="sidebar-administration")

View file

@ -0,0 +1,78 @@
defmodule MvWeb.StatisticsLiveTest do
@moduledoc """
Tests for the Statistics LiveView at /statistics.
Uses explicit auth: conn is authenticated with a role that has access to
the statistics page (read_only by default; override with @tag :role).
"""
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
alias Mv.MembershipFees.MembershipFeeType
describe "statistics page" do
@describetag role: :read_only
test "renders statistics page with title and key labels for authenticated user with access",
%{
conn: conn
} do
{:ok, _view, html} = live(conn, ~p"/statistics")
assert html =~ "Statistics"
assert html =~ "Active members"
assert html =~ "Unpaid"
assert html =~ "Contributions by year"
assert html =~ "Member numbers by year"
end
test "page shows overview of all relevant years without year selector", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/statistics")
# No year dropdown: single select for year should not be present as main control
assert html =~ "Overview" or html =~ "overview"
# table header or legend
assert html =~ "Year"
end
test "fee_type_id in URL updates selected filter and contributions", %{conn: conn} do
actor = Mv.Helpers.SystemActor.get_system_actor()
fee_types =
MembershipFeeType
|> Ash.Query.sort(name: :asc)
|> Ash.read!(domain: Mv.MembershipFees, actor: actor)
fee_type =
case List.first(fee_types) do
nil ->
MembershipFeeType
|> Ash.Changeset.for_create(:create, %{
name: "Test Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
})
|> Ash.create!(actor: actor)
ft ->
ft
end
path = ~p"/statistics" <> "?" <> URI.encode_query(%{"fee_type_id" => fee_type.id})
{:ok, view, html} = live(conn, path)
assert view |> element("select#fee-type-filter") |> has_element?()
assert html =~ fee_type.name
assert html =~ "Contributions by year"
end
end
describe "statistics page with own_data role" do
@describetag role: :member
test "redirects when user has only own_data (no access to statistics page)", %{conn: conn} do
# member role uses own_data permission set; /statistics is not in own_data pages
conn = get(conn, ~p"/statistics")
assert redirected_to(conn) != ~p"/statistics"
end
end
end

View file

@ -522,7 +522,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end
end
describe "export to CSV" do
describe "export dropdown" do
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
@ -535,34 +535,139 @@ defmodule MvWeb.MemberLive.IndexTest do
%{member1: m1}
end
test "export button is rendered when no selection and shows (all)", %{conn: conn} do
test "export dropdown button is rendered when no selection and shows (all)", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Dropdown button should be present
assert html =~ ~s(data-testid="export-dropdown")
assert html =~ ~s(data-testid="export-dropdown-button")
assert html =~ "Export"
# Button text shows "all" when 0 selected (locale-dependent)
assert html =~ "Export to CSV"
assert html =~ "all" or html =~ "All"
end
test "after select_member event export button shows (1)", %{conn: conn, member1: member1} do
test "after select_member event export dropdown shows (1)", %{conn: conn, member1: member1} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
render_click(view, "select_member", %{"id" => member1.id})
html = render(view)
assert html =~ "Export to CSV"
assert html =~ "Export"
assert html =~ "(1)"
end
test "form has correct action and payload hidden input", %{conn: conn} do
test "dropdown opens and closes on click", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
{:ok, view, _html} = live(conn, "/members")
# Initially closed
refute has_element?(view, ~s([data-testid="export-dropdown-menu"]))
# Click to open
view
|> element(~s([data-testid="export-dropdown-button"]))
|> render_click()
# Menu should be visible
assert has_element?(view, ~s([data-testid="export-dropdown-menu"]))
# Click to close
view
|> element(~s([data-testid="export-dropdown-button"]))
|> render_click()
# Menu should be hidden
refute has_element?(view, ~s([data-testid="export-dropdown-menu"]))
end
test "dropdown has click-away and ESC handlers", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element(~s([data-testid="export-dropdown-button"]))
|> render_click()
html = render(view)
assert has_element?(view, ~s([data-testid="export-dropdown-menu"]))
# Check that click-away handler is present
assert html =~ ~s(phx-click-away="close_dropdown")
# Check that ESC handler is present
assert html =~ ~s(phx-window-keydown="close_dropdown")
assert html =~ ~s(phx-key="Escape")
end
test "dropdown menu contains CSV and PDF export links with correct payload", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element(~s([data-testid="export-dropdown-button"]))
|> render_click()
html = render(view)
# Check CSV link
assert html =~ ~s(data-testid="export-csv-link")
assert html =~ "/members/export.csv"
assert html =~ ~s(name="payload")
assert html =~ ~s(type="hidden")
assert html =~ ~s(name="_csrf_token")
# Check PDF link
assert html =~ ~s(data-testid="export-pdf-link")
assert html =~ "/members/export.pdf"
assert html =~ ~s(name="payload")
assert html =~ ~s(type="hidden")
assert html =~ ~s(name="_csrf_token")
# Both forms should have the same payload
csv_form_payload = extract_payload_from_form(html, "/members/export.csv")
pdf_form_payload = extract_payload_from_form(html, "/members/export.pdf")
assert csv_form_payload == pdf_form_payload
assert csv_form_payload != nil
end
test "dropdown has correct ARIA attributes", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
html = render(view)
# Button should have aria-haspopup="menu"
assert html =~ ~s(aria-haspopup="menu")
# Button should have aria-expanded="false" when closed
assert html =~ ~s(aria-expanded="false")
# Button should have aria-controls pointing to menu
assert html =~ ~s(aria-controls="export-dropdown-menu")
# Open dropdown
view
|> element(~s([data-testid="export-dropdown-button"]))
|> render_click()
html = render(view)
# Button should have aria-expanded="true" when open
assert html =~ ~s(aria-expanded="true")
# Menu should have role="menu"
assert html =~ ~s(role="menu")
end
# Helper to extract payload value from form HTML
defp extract_payload_from_form(html, action_path) do
case Regex.run(
~r/<form[^>]*action="#{Regex.escape(action_path)}"[^>]*>.*?<input[^>]*name="payload"[^>]*value="([^"]+)"/s,
html
) do
[_, payload] -> payload
_ -> nil
end
end
end

View file

@ -107,6 +107,37 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
end
end
describe "statistics route /statistics" do
test "read_only can access /statistics" do
user = Fixtures.user_with_role_fixture("read_only")
conn = conn_with_user("/statistics", user) |> CheckPagePermission.call([])
refute conn.halted
end
test "normal_user can access /statistics" do
user = Fixtures.user_with_role_fixture("normal_user")
conn = conn_with_user("/statistics", user) |> CheckPagePermission.call([])
refute conn.halted
end
test "admin can access /statistics" do
user = Fixtures.user_with_role_fixture("admin")
conn = conn_with_user("/statistics", user) |> CheckPagePermission.call([])
refute conn.halted
end
test "own_data cannot access /statistics" do
user = Fixtures.user_with_role_fixture("own_data")
conn = conn_with_user("/statistics", user) |> CheckPagePermission.call([])
assert conn.halted
assert redirected_to(conn) == "/users/#{user.id}"
end
end
describe "read_only and normal_user denied on admin routes" do
test "read_only cannot access /admin/roles" do
user = Fixtures.user_with_role_fixture("read_only")