Merge remote-tracking branch 'origin/main' into feature/ui-for-adding-members-groups
This commit is contained in:
commit
2f8a6a2768
136 changed files with 9999 additions and 3601 deletions
|
|
@ -50,14 +50,14 @@ defmodule MvWeb.AuthorizationTest do
|
|||
assert Authorization.can?(admin, :destroy, Mv.Authorization.Role) == true
|
||||
end
|
||||
|
||||
test "non-admin cannot manage roles" do
|
||||
test "non-admin can read roles but cannot create/update/destroy" do
|
||||
normal_user = %{
|
||||
id: "normal-123",
|
||||
role: %{permission_set_name: "normal_user"}
|
||||
}
|
||||
|
||||
assert Authorization.can?(normal_user, :read, Mv.Authorization.Role) == true
|
||||
assert Authorization.can?(normal_user, :create, Mv.Authorization.Role) == false
|
||||
assert Authorization.can?(normal_user, :read, Mv.Authorization.Role) == false
|
||||
assert Authorization.can?(normal_user, :update, Mv.Authorization.Role) == false
|
||||
assert Authorization.can?(normal_user, :destroy, Mv.Authorization.Role) == false
|
||||
end
|
||||
|
|
|
|||
|
|
@ -22,9 +22,14 @@ defmodule MvWeb.Layouts.SidebarTest do
|
|||
# =============================================================================
|
||||
|
||||
# Returns assigns for an authenticated user with all required attributes.
|
||||
# User has admin role so can_access_page? returns true for all sidebar links.
|
||||
defp authenticated_assigns(mobile \\ false) do
|
||||
%{
|
||||
current_user: %{id: "user-123", email: "test@example.com"},
|
||||
current_user: %{
|
||||
id: "user-123",
|
||||
email: "test@example.com",
|
||||
role: %{permission_set_name: "admin"}
|
||||
},
|
||||
club_name: "Test Club",
|
||||
mobile: mobile
|
||||
}
|
||||
|
|
@ -144,7 +149,9 @@ defmodule MvWeb.Layouts.SidebarTest do
|
|||
assert menu_item_count > 0, "Should have at least one top-level menu item"
|
||||
|
||||
# Check that nested menu groups exist
|
||||
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
|
||||
assert html =~
|
||||
~s(<li role="none" class="expanded-menu-group" data-testid="sidebar-administration">)
|
||||
|
||||
assert html =~ ~s(role="group")
|
||||
assert has_class?(html, "expanded-menu-group")
|
||||
|
||||
|
|
@ -193,7 +200,9 @@ defmodule MvWeb.Layouts.SidebarTest do
|
|||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Check for nested menu structure
|
||||
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
|
||||
assert html =~
|
||||
~s(<li role="none" class="expanded-menu-group" data-testid="sidebar-administration">)
|
||||
|
||||
assert html =~ ~s(role="group")
|
||||
assert html =~ ~s(aria-label="Administration")
|
||||
assert has_class?(html, "expanded-menu-group")
|
||||
|
|
@ -521,7 +530,9 @@ defmodule MvWeb.Layouts.SidebarTest do
|
|||
assert html =~ ~s(role="menuitem")
|
||||
|
||||
# Check that nested menus exist
|
||||
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
|
||||
assert html =~
|
||||
~s(<li role="none" class="expanded-menu-group" data-testid="sidebar-administration">)
|
||||
|
||||
assert html =~ ~s(role="group")
|
||||
|
||||
# Footer section
|
||||
|
|
@ -629,7 +640,9 @@ defmodule MvWeb.Layouts.SidebarTest do
|
|||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# expanded-menu-group structure present
|
||||
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
|
||||
assert html =~
|
||||
~s(<li role="none" class="expanded-menu-group" data-testid="sidebar-administration">)
|
||||
|
||||
assert html =~ ~s(role="group")
|
||||
assert html =~ ~s(aria-label="Administration")
|
||||
assert has_class?(html, "expanded-menu-group")
|
||||
|
|
@ -843,7 +856,9 @@ defmodule MvWeb.Layouts.SidebarTest do
|
|||
|
||||
# Expanded menu group should have correct structure
|
||||
# (CSS handles hover effects, but we verify structure)
|
||||
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
|
||||
assert html =~
|
||||
~s(<li role="none" class="expanded-menu-group" data-testid="sidebar-administration">)
|
||||
|
||||
assert html =~ ~s(role="group")
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -15,19 +15,19 @@ defmodule MvWeb.Components.SearchBarComponentTest do
|
|||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# simulate search input and check that other members are not listed
|
||||
html =
|
||||
_html =
|
||||
view
|
||||
|> element("form[role=search]")
|
||||
|> render_submit(%{"query" => "Friedrich"})
|
||||
|
||||
refute html =~ "Greta"
|
||||
refute has_element?(view, "input[data-testid='search-input'][value='Greta']")
|
||||
|
||||
html =
|
||||
_html =
|
||||
view
|
||||
|> element("form[role=search]")
|
||||
|> render_submit(%{"query" => "Greta"})
|
||||
|
||||
refute html =~ "Friedrich"
|
||||
refute has_element?(view, "input[data-testid='search-input'][value='Friedrich']")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
120
test/mv_web/components/sidebar_authorization_test.exs
Normal file
120
test/mv_web/components/sidebar_authorization_test.exs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
defmodule MvWeb.SidebarAuthorizationTest do
|
||||
@moduledoc """
|
||||
Tests for sidebar menu visibility based on user permissions (can_access_page?).
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import MvWeb.Layouts.Sidebar
|
||||
|
||||
alias Mv.Fixtures
|
||||
|
||||
defp render_sidebar(assigns) do
|
||||
render_component(&sidebar/1, assigns)
|
||||
end
|
||||
|
||||
defp sidebar_assigns(current_user, opts \\ []) do
|
||||
mobile = Keyword.get(opts, :mobile, false)
|
||||
club_name = Keyword.get(opts, :club_name, "Test Club")
|
||||
|
||||
%{
|
||||
current_user: current_user,
|
||||
club_name: club_name,
|
||||
mobile: mobile
|
||||
}
|
||||
end
|
||||
|
||||
describe "sidebar menu with admin user" do
|
||||
test "shows Members, Fee Types 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(data-testid="sidebar-administration")
|
||||
assert html =~ ~s(href="/users")
|
||||
assert html =~ ~s(href="/groups")
|
||||
assert html =~ ~s(href="/admin/roles")
|
||||
assert html =~ ~s(href="/membership_fee_settings")
|
||||
assert html =~ ~s(href="/settings")
|
||||
end
|
||||
end
|
||||
|
||||
describe "sidebar menu with read_only user (Vorstand/Buchhaltung)" do
|
||||
test "shows Members 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="/groups")
|
||||
end
|
||||
|
||||
test "does not show Fee Types, Users, Roles or Settings" do
|
||||
user = Fixtures.user_with_role_fixture("read_only")
|
||||
html = render_sidebar(sidebar_assigns(user))
|
||||
|
||||
refute html =~ ~s(href="/membership_fee_types")
|
||||
refute html =~ ~s(href="/users")
|
||||
refute html =~ ~s(href="/admin/roles")
|
||||
refute html =~ ~s(href="/settings")
|
||||
end
|
||||
end
|
||||
|
||||
describe "sidebar menu with normal_user (Kassenwart)" do
|
||||
test "shows Members 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="/groups")
|
||||
end
|
||||
|
||||
test "does not show Fee Types, Users, Roles or Settings" do
|
||||
user = Fixtures.user_with_role_fixture("normal_user")
|
||||
html = render_sidebar(sidebar_assigns(user))
|
||||
|
||||
refute html =~ ~s(href="/membership_fee_types")
|
||||
refute html =~ ~s(href="/users")
|
||||
refute html =~ ~s(href="/admin/roles")
|
||||
refute html =~ ~s(href="/settings")
|
||||
end
|
||||
end
|
||||
|
||||
describe "sidebar menu with own_data user (Mitglied)" do
|
||||
test "does not show Members link (no /members page access)" do
|
||||
user = Fixtures.user_with_role_fixture("own_data")
|
||||
html = render_sidebar(sidebar_assigns(user))
|
||||
|
||||
refute html =~ ~s(href="/members")
|
||||
end
|
||||
|
||||
test "does not show Fee Types or Administration" do
|
||||
user = Fixtures.user_with_role_fixture("own_data")
|
||||
html = render_sidebar(sidebar_assigns(user))
|
||||
|
||||
refute html =~ ~s(href="/membership_fee_types")
|
||||
refute html =~ ~s(href="/users")
|
||||
refute html =~ ~s(data-testid="sidebar-administration")
|
||||
end
|
||||
end
|
||||
|
||||
describe "sidebar with nil current_user" do
|
||||
test "does not render menu items (only header and footer when present)" do
|
||||
html = render_sidebar(sidebar_assigns(nil))
|
||||
|
||||
refute html =~ ~s(role="menubar")
|
||||
refute html =~ ~s(href="/members")
|
||||
end
|
||||
end
|
||||
|
||||
describe "sidebar with user without role" do
|
||||
test "does not show any navigation links" do
|
||||
user = %{id: "user-no-role", email: "noreply@test.com", role: nil}
|
||||
html = render_sidebar(sidebar_assigns(user))
|
||||
|
||||
refute html =~ ~s(href="/members")
|
||||
refute html =~ ~s(href="/membership_fee_types")
|
||||
refute html =~ ~s(href="/users")
|
||||
end
|
||||
end
|
||||
end
|
||||
504
test/mv_web/controllers/member_export_controller_test.exs
Normal file
504
test/mv_web/controllers/member_export_controller_test.exs
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
defmodule MvWeb.MemberExportControllerTest do
|
||||
use MvWeb.ConnCase, async: true
|
||||
|
||||
alias Mv.Fixtures
|
||||
|
||||
defp csrf_token_from_conn(conn) do
|
||||
get_session(conn, "_csrf_token") || csrf_token_from_html(response(conn, 200))
|
||||
end
|
||||
|
||||
defp csrf_token_from_html(html) when is_binary(html) do
|
||||
case Regex.run(~r/name="csrf-token"\s+content="([^"]+)"/, html) do
|
||||
[_, token] -> token
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
# Export uses humanize_field (e.g. "first_name" -> "First name"); normalize \r\n line endings
|
||||
defp export_lines(body) do
|
||||
body |> String.split(~r/\r?\n/, trim: true)
|
||||
end
|
||||
|
||||
describe "POST /members/export.csv" do
|
||||
setup %{conn: conn} do
|
||||
# Create 3 members for export tests
|
||||
m1 =
|
||||
Fixtures.member_fixture(%{
|
||||
first_name: "Alice",
|
||||
last_name: "One",
|
||||
email: "alice.one@example.com"
|
||||
})
|
||||
|
||||
m2 =
|
||||
Fixtures.member_fixture(%{
|
||||
first_name: "Bob",
|
||||
last_name: "Two",
|
||||
email: "bob.two@example.com"
|
||||
})
|
||||
|
||||
m3 =
|
||||
Fixtures.member_fixture(%{
|
||||
first_name: "Carol",
|
||||
last_name: "Three",
|
||||
email: "carol.three@example.com"
|
||||
})
|
||||
|
||||
%{member1: m1, member2: m2, member3: m3, conn: conn}
|
||||
end
|
||||
|
||||
test "exports selected members with specified fields", %{
|
||||
conn: conn,
|
||||
member1: m1,
|
||||
member2: m2
|
||||
} do
|
||||
payload = %{
|
||||
"selected_ids" => [m1.id, m2.id],
|
||||
"member_fields" => ["first_name", "last_name", "email"],
|
||||
"custom_field_ids" => [],
|
||||
"query" => nil,
|
||||
"sort_field" => nil,
|
||||
"sort_order" => nil
|
||||
}
|
||||
|
||||
conn = get(conn, "/members")
|
||||
csrf_token = csrf_token_from_conn(conn)
|
||||
|
||||
conn =
|
||||
post(conn, "/members/export.csv", %{
|
||||
"payload" => Jason.encode!(payload),
|
||||
"_csrf_token" => csrf_token
|
||||
})
|
||||
|
||||
assert conn.status == 200
|
||||
assert get_resp_header(conn, "content-type") |> List.first() =~ "text/csv"
|
||||
|
||||
body = response(conn, 200)
|
||||
lines = export_lines(body)
|
||||
header = hd(lines)
|
||||
|
||||
# Header + 2 data rows (controller uses humanize_field: "first_name" -> "First name")
|
||||
assert length(lines) == 3
|
||||
assert header =~ "First Name,Last Name,Email"
|
||||
assert body =~ "Alice"
|
||||
assert body =~ "Bob"
|
||||
refute body =~ "Carol"
|
||||
end
|
||||
|
||||
test "exports all members when selected_ids is empty", %{
|
||||
conn: conn,
|
||||
member1: _m1,
|
||||
member2: _m2,
|
||||
member3: _m3
|
||||
} do
|
||||
payload = %{
|
||||
"selected_ids" => [],
|
||||
"member_fields" => ["first_name", "email"],
|
||||
"custom_field_ids" => [],
|
||||
"query" => nil,
|
||||
"sort_field" => nil,
|
||||
"sort_order" => nil
|
||||
}
|
||||
|
||||
conn = get(conn, "/members")
|
||||
csrf_token = csrf_token_from_conn(conn)
|
||||
|
||||
conn =
|
||||
post(conn, "/members/export.csv", %{
|
||||
"payload" => Jason.encode!(payload),
|
||||
"_csrf_token" => csrf_token
|
||||
})
|
||||
|
||||
assert conn.status == 200
|
||||
body = response(conn, 200)
|
||||
lines = export_lines(body)
|
||||
|
||||
# Header + at least 3 data rows (controller uses humanize_field)
|
||||
assert length(lines) >= 4
|
||||
assert body =~ "Alice"
|
||||
assert body =~ "Bob"
|
||||
assert body =~ "Carol"
|
||||
end
|
||||
|
||||
test "filters out unknown member fields from export", %{conn: conn, member1: m1} do
|
||||
payload = %{
|
||||
"selected_ids" => [m1.id],
|
||||
"member_fields" => ["first_name", "unknown_field", "email"],
|
||||
"custom_field_ids" => [],
|
||||
"query" => nil,
|
||||
"sort_field" => nil,
|
||||
"sort_order" => nil
|
||||
}
|
||||
|
||||
conn = get(conn, "/members")
|
||||
csrf_token = csrf_token_from_conn(conn)
|
||||
|
||||
conn =
|
||||
post(conn, "/members/export.csv", %{
|
||||
"payload" => Jason.encode!(payload),
|
||||
"_csrf_token" => csrf_token
|
||||
})
|
||||
|
||||
assert conn.status == 200
|
||||
body = response(conn, 200)
|
||||
header = body |> export_lines() |> hd()
|
||||
|
||||
assert header =~ "First Name,Email"
|
||||
refute header =~ "unknown_field"
|
||||
end
|
||||
|
||||
test "export includes membership_fee_status computed field when requested", %{
|
||||
conn: conn,
|
||||
member1: m1
|
||||
} do
|
||||
payload = %{
|
||||
"selected_ids" => [m1.id],
|
||||
"member_fields" => ["first_name"],
|
||||
"computed_fields" => ["membership_fee_status"],
|
||||
"custom_field_ids" => [],
|
||||
"query" => nil,
|
||||
"sort_field" => nil,
|
||||
"sort_order" => nil
|
||||
}
|
||||
|
||||
conn = get(conn, "/members")
|
||||
csrf_token = csrf_token_from_conn(conn)
|
||||
|
||||
conn =
|
||||
post(conn, "/members/export.csv", %{
|
||||
"payload" => Jason.encode!(payload),
|
||||
"_csrf_token" => csrf_token
|
||||
})
|
||||
|
||||
assert conn.status == 200
|
||||
body = response(conn, 200)
|
||||
header = body |> export_lines() |> hd()
|
||||
|
||||
assert header =~ "First Name,Membership Fee Status"
|
||||
assert body =~ "Alice"
|
||||
end
|
||||
|
||||
test "exports membership fee status computed field with show_current_cycle option", %{
|
||||
conn: conn,
|
||||
member1: _m1,
|
||||
member2: _m2,
|
||||
member3: _m3
|
||||
} do
|
||||
payload = %{
|
||||
"selected_ids" => [],
|
||||
"member_fields" => [],
|
||||
"computed_fields" => ["membership_fee_status"],
|
||||
"custom_field_ids" => [],
|
||||
"query" => nil,
|
||||
"sort_field" => nil,
|
||||
"sort_order" => nil,
|
||||
"show_current_cycle" => true
|
||||
}
|
||||
|
||||
conn = get(conn, "/members")
|
||||
csrf_token = csrf_token_from_conn(conn)
|
||||
|
||||
conn =
|
||||
post(conn, "/members/export.csv", %{
|
||||
"payload" => Jason.encode!(payload),
|
||||
"_csrf_token" => csrf_token
|
||||
})
|
||||
|
||||
assert conn.status == 200
|
||||
body = response(conn, 200)
|
||||
lines = export_lines(body)
|
||||
header = hd(lines)
|
||||
|
||||
assert header =~ "Membership Fee Status"
|
||||
end
|
||||
|
||||
setup %{conn: conn} do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
# Create custom fields for different types
|
||||
{:ok, string_field} =
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Phone Number",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
|
||||
{:ok, integer_field} =
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Membership Number",
|
||||
value_type: :integer
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
|
||||
{:ok, boolean_field} =
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Active Member",
|
||||
value_type: :boolean
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
|
||||
# Create members with custom field values
|
||||
{:ok, member_with_string} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "String",
|
||||
email: "test.string@example.com"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, _cfv_string} =
|
||||
Mv.Membership.CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member_with_string.id,
|
||||
custom_field_id: string_field.id,
|
||||
value: "+49 123 456789"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
|
||||
{:ok, member_with_integer} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "Integer",
|
||||
email: "test.integer@example.com"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, _cfv_integer} =
|
||||
Mv.Membership.CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member_with_integer.id,
|
||||
custom_field_id: integer_field.id,
|
||||
value: 12_345
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
|
||||
{:ok, member_with_boolean} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "Boolean",
|
||||
email: "test.boolean@example.com"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, _cfv_boolean} =
|
||||
Mv.Membership.CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member_with_boolean.id,
|
||||
custom_field_id: boolean_field.id,
|
||||
value: true
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
|
||||
{:ok, member_without_value} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "NoValue",
|
||||
email: "test.novalue@example.com"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
%{
|
||||
conn: conn,
|
||||
string_field: string_field,
|
||||
integer_field: integer_field,
|
||||
boolean_field: boolean_field,
|
||||
member_with_string: member_with_string,
|
||||
member_with_integer: member_with_integer,
|
||||
member_with_boolean: member_with_boolean,
|
||||
member_without_value: member_without_value
|
||||
}
|
||||
end
|
||||
|
||||
test "export includes custom field column with string value", %{
|
||||
conn: conn,
|
||||
string_field: string_field,
|
||||
member_with_string: member
|
||||
} do
|
||||
payload = %{
|
||||
"selected_ids" => [member.id],
|
||||
"member_fields" => ["first_name", "last_name"],
|
||||
"custom_field_ids" => [string_field.id],
|
||||
"query" => nil,
|
||||
"sort_field" => nil,
|
||||
"sort_order" => nil
|
||||
}
|
||||
|
||||
conn = get(conn, "/members")
|
||||
csrf_token = csrf_token_from_conn(conn)
|
||||
|
||||
conn =
|
||||
post(conn, "/members/export.csv", %{
|
||||
"payload" => Jason.encode!(payload),
|
||||
"_csrf_token" => csrf_token
|
||||
})
|
||||
|
||||
assert conn.status == 200
|
||||
body = response(conn, 200)
|
||||
lines = export_lines(body)
|
||||
header = hd(lines)
|
||||
|
||||
assert header =~ "First Name"
|
||||
assert header =~ "Last Name"
|
||||
assert header =~ "Phone Number"
|
||||
assert body =~ "Test"
|
||||
assert body =~ "String"
|
||||
assert body =~ "+49 123 456789"
|
||||
end
|
||||
|
||||
test "export includes custom field column with integer value", %{
|
||||
conn: conn,
|
||||
integer_field: integer_field,
|
||||
member_with_integer: member
|
||||
} do
|
||||
payload = %{
|
||||
"selected_ids" => [member.id],
|
||||
"member_fields" => ["first_name"],
|
||||
"custom_field_ids" => [integer_field.id],
|
||||
"query" => nil,
|
||||
"sort_field" => nil,
|
||||
"sort_order" => nil
|
||||
}
|
||||
|
||||
conn = get(conn, "/members")
|
||||
csrf_token = csrf_token_from_conn(conn)
|
||||
|
||||
conn =
|
||||
post(conn, "/members/export.csv", %{
|
||||
"payload" => Jason.encode!(payload),
|
||||
"_csrf_token" => csrf_token
|
||||
})
|
||||
|
||||
assert conn.status == 200
|
||||
body = response(conn, 200)
|
||||
header = body |> export_lines() |> hd()
|
||||
|
||||
assert header =~ "First Name"
|
||||
assert header =~ "Membership Number"
|
||||
assert body =~ "Test"
|
||||
assert body =~ "12345"
|
||||
end
|
||||
|
||||
test "export includes custom field column with boolean value", %{
|
||||
conn: conn,
|
||||
boolean_field: boolean_field,
|
||||
member_with_boolean: member
|
||||
} do
|
||||
payload = %{
|
||||
"selected_ids" => [member.id],
|
||||
"member_fields" => ["first_name"],
|
||||
"custom_field_ids" => [boolean_field.id],
|
||||
"query" => nil,
|
||||
"sort_field" => nil,
|
||||
"sort_order" => nil
|
||||
}
|
||||
|
||||
conn = get(conn, "/members")
|
||||
csrf_token = csrf_token_from_conn(conn)
|
||||
|
||||
conn =
|
||||
post(conn, "/members/export.csv", %{
|
||||
"payload" => Jason.encode!(payload),
|
||||
"_csrf_token" => csrf_token
|
||||
})
|
||||
|
||||
assert conn.status == 200
|
||||
body = response(conn, 200)
|
||||
header = body |> export_lines() |> hd()
|
||||
|
||||
assert header =~ "First Name"
|
||||
assert header =~ "Active Member"
|
||||
assert body =~ "Test"
|
||||
# Boolean values are formatted as "Yes" or "No" by CustomFieldValueFormatter
|
||||
assert body =~ "Yes"
|
||||
end
|
||||
|
||||
test "export shows empty cell for member without custom field value", %{
|
||||
conn: conn,
|
||||
string_field: string_field,
|
||||
member_without_value: member
|
||||
} do
|
||||
payload = %{
|
||||
"selected_ids" => [member.id],
|
||||
"member_fields" => ["first_name", "last_name"],
|
||||
"custom_field_ids" => [string_field.id],
|
||||
"query" => nil,
|
||||
"sort_field" => nil,
|
||||
"sort_order" => nil
|
||||
}
|
||||
|
||||
conn = get(conn, "/members")
|
||||
csrf_token = csrf_token_from_conn(conn)
|
||||
|
||||
conn =
|
||||
post(conn, "/members/export.csv", %{
|
||||
"payload" => Jason.encode!(payload),
|
||||
"_csrf_token" => csrf_token
|
||||
})
|
||||
|
||||
assert conn.status == 200
|
||||
body = response(conn, 200)
|
||||
lines = export_lines(body)
|
||||
header = hd(lines)
|
||||
data_line = Enum.at(lines, 1)
|
||||
|
||||
assert header =~ "Phone Number"
|
||||
# Empty custom field value should result in empty cell (two consecutive commas)
|
||||
assert data_line =~ "Test,NoValue,"
|
||||
end
|
||||
|
||||
test "export includes multiple custom fields in correct order", %{
|
||||
conn: conn,
|
||||
string_field: string_field,
|
||||
integer_field: integer_field,
|
||||
boolean_field: boolean_field,
|
||||
member_with_string: member
|
||||
} do
|
||||
payload = %{
|
||||
"selected_ids" => [member.id],
|
||||
"member_fields" => ["first_name"],
|
||||
"custom_field_ids" => [string_field.id, integer_field.id, boolean_field.id],
|
||||
"query" => nil,
|
||||
"sort_field" => nil,
|
||||
"sort_order" => nil
|
||||
}
|
||||
|
||||
conn = get(conn, "/members")
|
||||
csrf_token = csrf_token_from_conn(conn)
|
||||
|
||||
conn =
|
||||
post(conn, "/members/export.csv", %{
|
||||
"payload" => Jason.encode!(payload),
|
||||
"_csrf_token" => csrf_token
|
||||
})
|
||||
|
||||
assert conn.status == 200
|
||||
body = response(conn, 200)
|
||||
header = body |> export_lines() |> hd()
|
||||
|
||||
assert header =~ "First Name"
|
||||
assert header =~ "Phone Number"
|
||||
assert header =~ "Membership Number"
|
||||
assert header =~ "Active Member"
|
||||
# Verify order: member fields first, then custom fields in the order specified
|
||||
header_parts = String.split(header, ",")
|
||||
first_name_idx = Enum.find_index(header_parts, &String.contains?(&1, "First Name"))
|
||||
phone_idx = Enum.find_index(header_parts, &String.contains?(&1, "Phone Number"))
|
||||
membership_idx = Enum.find_index(header_parts, &String.contains?(&1, "Membership Number"))
|
||||
active_idx = Enum.find_index(header_parts, &String.contains?(&1, "Active Member"))
|
||||
|
||||
assert first_name_idx < phone_idx
|
||||
assert phone_idx < membership_idx
|
||||
assert membership_idx < active_idx
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -37,7 +37,7 @@ defmodule MvWeb.OidcE2EFlowTest do
|
|||
assert is_nil(new_user.hashed_password)
|
||||
|
||||
# Verify user can be found by oidc_id
|
||||
{:ok, [found_user]} =
|
||||
result =
|
||||
Mv.Accounts.read_sign_in_with_rauthy(
|
||||
%{
|
||||
user_info: user_info,
|
||||
|
|
@ -46,6 +46,13 @@ defmodule MvWeb.OidcE2EFlowTest do
|
|||
actor: actor
|
||||
)
|
||||
|
||||
found_user =
|
||||
case result do
|
||||
{:ok, u} when is_struct(u) -> u
|
||||
{:ok, [u]} -> u
|
||||
_ -> flunk("Expected user, got: #{inspect(result)}")
|
||||
end
|
||||
|
||||
assert found_user.id == new_user.id
|
||||
end
|
||||
end
|
||||
|
|
@ -177,7 +184,7 @@ defmodule MvWeb.OidcE2EFlowTest do
|
|||
assert linked_user.hashed_password == password_user.hashed_password
|
||||
|
||||
# Step 5: User can now sign in via OIDC
|
||||
{:ok, [signed_in_user]} =
|
||||
result =
|
||||
Mv.Accounts.read_sign_in_with_rauthy(
|
||||
%{
|
||||
user_info: user_info,
|
||||
|
|
@ -186,6 +193,13 @@ defmodule MvWeb.OidcE2EFlowTest do
|
|||
actor: actor
|
||||
)
|
||||
|
||||
signed_in_user =
|
||||
case result do
|
||||
{:ok, u} when is_struct(u) -> u
|
||||
{:ok, [u]} -> u
|
||||
_ -> flunk("Expected user, got: #{inspect(result)}")
|
||||
end
|
||||
|
||||
assert signed_in_user.id == password_user.id
|
||||
assert signed_in_user.oidc_id == "oidc_link_888"
|
||||
end
|
||||
|
|
@ -331,6 +345,9 @@ defmodule MvWeb.OidcE2EFlowTest do
|
|||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:ok, nil} ->
|
||||
:ok
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
:ok
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ defmodule MvWeb.OidcIntegrationTest do
|
|||
# Test sign_in_with_rauthy action directly
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, [found_user]} =
|
||||
result =
|
||||
Mv.Accounts.read_sign_in_with_rauthy(
|
||||
%{
|
||||
user_info: user_info,
|
||||
|
|
@ -36,6 +36,13 @@ defmodule MvWeb.OidcIntegrationTest do
|
|||
actor: system_actor
|
||||
)
|
||||
|
||||
found_user =
|
||||
case result do
|
||||
{:ok, u} when is_struct(u) -> u
|
||||
{:ok, [u]} -> u
|
||||
_ -> flunk("Expected user, got: #{inspect(result)}")
|
||||
end
|
||||
|
||||
assert found_user.id == user.id
|
||||
assert to_string(found_user.email) == "existing@example.com"
|
||||
assert found_user.oidc_id == "existing_oidc_123"
|
||||
|
|
@ -104,6 +111,9 @@ defmodule MvWeb.OidcIntegrationTest do
|
|||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:ok, nil} ->
|
||||
:ok
|
||||
|
||||
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
||||
:ok
|
||||
|
||||
|
|
@ -129,7 +139,7 @@ defmodule MvWeb.OidcIntegrationTest do
|
|||
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, [found_user]} =
|
||||
result =
|
||||
Mv.Accounts.read_sign_in_with_rauthy(
|
||||
%{
|
||||
user_info: correct_user_info,
|
||||
|
|
@ -138,6 +148,13 @@ defmodule MvWeb.OidcIntegrationTest do
|
|||
actor: system_actor
|
||||
)
|
||||
|
||||
found_user =
|
||||
case result do
|
||||
{:ok, u} when is_struct(u) -> u
|
||||
{:ok, [u]} -> u
|
||||
_ -> flunk("Expected user, got: #{inspect(result)}")
|
||||
end
|
||||
|
||||
assert found_user.id == user.id
|
||||
|
||||
# Try with wrong oidc_id but correct email
|
||||
|
|
@ -155,11 +172,14 @@ defmodule MvWeb.OidcIntegrationTest do
|
|||
actor: system_actor
|
||||
)
|
||||
|
||||
# Either returns empty list OR authentication error - both mean "user not found"
|
||||
# Either returns empty/nil OR authentication error - both mean "user not found"
|
||||
case result do
|
||||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:ok, nil} ->
|
||||
:ok
|
||||
|
||||
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
||||
:ok
|
||||
|
||||
|
|
@ -193,11 +213,14 @@ defmodule MvWeb.OidcIntegrationTest do
|
|||
actor: system_actor
|
||||
)
|
||||
|
||||
# Either returns empty list OR authentication error - both mean "user not found"
|
||||
# Either returns empty/nil OR authentication error - both mean "user not found"
|
||||
case result do
|
||||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:ok, nil} ->
|
||||
:ok
|
||||
|
||||
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
||||
:ok
|
||||
|
||||
|
|
|
|||
|
|
@ -134,8 +134,8 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
|||
# Load cycles with membership_fee_type relationship
|
||||
member =
|
||||
member
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: actor)
|
||||
|> Ash.load!(:membership_fee_type, actor: actor)
|
||||
|
||||
# Use a fixed date in 2024 to ensure 2023 is last completed
|
||||
today = ~D[2024-06-15]
|
||||
|
|
@ -180,8 +180,8 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
|||
# Load cycles and fee type (will be empty)
|
||||
member =
|
||||
member
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: actor)
|
||||
|> Ash.load!(:membership_fee_type, actor: actor)
|
||||
|
||||
last_cycle = MembershipFeeHelpers.get_last_completed_cycle(member, Date.utc_today())
|
||||
assert last_cycle == nil
|
||||
|
|
@ -245,8 +245,8 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
|||
# Load cycles with membership_fee_type relationship
|
||||
member =
|
||||
member
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: actor)
|
||||
|> Ash.load!(:membership_fee_type, actor: actor)
|
||||
|
||||
result = MembershipFeeHelpers.get_current_cycle(member, today)
|
||||
|
||||
|
|
|
|||
|
|
@ -39,9 +39,10 @@ defmodule MvWeb.GlobalSettingsLiveConfigTest do
|
|||
original_config = Application.get_env(:mv, :csv_import, [])
|
||||
|
||||
try do
|
||||
# Arrange: Set custom row limit to 500
|
||||
Application.put_env(:mv, :csv_import, max_rows: 500)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
# Generate CSV with 501 rows (exceeding custom limit of 500)
|
||||
header = "first_name;last_name;email;street;postal_code;city\n"
|
||||
|
|
@ -53,17 +54,17 @@ defmodule MvWeb.GlobalSettingsLiveConfigTest do
|
|||
|
||||
large_csv = header <> Enum.join(rows)
|
||||
|
||||
# Simulate file upload using helper function
|
||||
# Act: Upload CSV and submit form
|
||||
upload_csv_file(view, large_csv, "too_many_rows_custom.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Assert: Import should be rejected with error message
|
||||
html = render(view)
|
||||
# Business rule: import should be rejected when exceeding configured limit
|
||||
assert html =~ "exceeds" or html =~ "maximum" or html =~ "limit" or
|
||||
html =~ "Failed to prepare"
|
||||
assert html =~ "Failed to prepare CSV import"
|
||||
after
|
||||
# Restore original config
|
||||
Application.put_env(:mv, :csv_import, original_config)
|
||||
|
|
|
|||
|
|
@ -3,22 +3,6 @@ defmodule MvWeb.GlobalSettingsLiveTest do
|
|||
import Phoenix.LiveViewTest
|
||||
alias Mv.Membership
|
||||
|
||||
# Helper function to upload CSV file in tests
|
||||
# Reduces code duplication across multiple test cases
|
||||
defp upload_csv_file(view, csv_content, filename \\ "test_import.csv") do
|
||||
view
|
||||
|> file_input("#csv-upload-form", :csv_file, [
|
||||
%{
|
||||
last_modified: System.system_time(:second),
|
||||
name: filename,
|
||||
content: csv_content,
|
||||
size: byte_size(csv_content),
|
||||
type: "text/csv"
|
||||
}
|
||||
])
|
||||
|> render_upload(filename)
|
||||
end
|
||||
|
||||
describe "Global Settings LiveView" do
|
||||
setup %{conn: conn} do
|
||||
user = create_test_user(%{email: "admin@example.com"})
|
||||
|
|
@ -97,595 +81,4 @@ defmodule MvWeb.GlobalSettingsLiveTest do
|
|||
assert render(view) =~ "updated" or render(view) =~ "success"
|
||||
end
|
||||
end
|
||||
|
||||
describe "CSV Import Section" do
|
||||
test "admin user sees import section", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/settings")
|
||||
|
||||
# Check for import section heading or identifier
|
||||
assert html =~ "Import" or html =~ "CSV" or html =~ "member_import"
|
||||
end
|
||||
|
||||
test "admin user sees custom fields notice", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/settings")
|
||||
|
||||
# Check for custom fields notice text
|
||||
assert html =~ "Use the data field name"
|
||||
end
|
||||
|
||||
test "admin user sees template download links", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Check for English template link
|
||||
assert html =~ "member_import_en.csv" or html =~ "/templates/member_import_en.csv"
|
||||
|
||||
# Check for German template link
|
||||
assert html =~ "member_import_de.csv" or html =~ "/templates/member_import_de.csv"
|
||||
end
|
||||
|
||||
test "template links use static path helper", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Check that links contain the static path pattern
|
||||
# Static paths typically start with /templates/ or contain the full path
|
||||
assert html =~ "/templates/member_import_en.csv" or
|
||||
html =~ ~r/href=["'][^"']*member_import_en\.csv["']/
|
||||
|
||||
assert html =~ "/templates/member_import_de.csv" or
|
||||
html =~ ~r/href=["'][^"']*member_import_de\.csv["']/
|
||||
end
|
||||
|
||||
test "admin user sees file upload input", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Check for file input element
|
||||
assert html =~ ~r/type=["']file["']/i or html =~ "phx-hook" or html =~ "upload"
|
||||
end
|
||||
|
||||
test "file upload has CSV-only restriction", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Check for CSV file type restriction in help text or accept attribute
|
||||
assert html =~ ~r/\.csv/i or html =~ "CSV" or html =~ ~r/accept=["'][^"']*csv["']/i
|
||||
end
|
||||
|
||||
test "non-admin user does not see import section", %{conn: conn} do
|
||||
# Member (own_data) is redirected when accessing /settings (no page permission)
|
||||
member_user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user)
|
||||
|
||||
assert {:error, {:redirect, %{to: to}}} = live(conn, ~p"/settings")
|
||||
assert to == "/users/#{member_user.id}"
|
||||
end
|
||||
end
|
||||
|
||||
describe "CSV Import - Import" do
|
||||
setup %{conn: conn} do
|
||||
# Ensure admin user
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
|
||||
|
||||
# Read valid CSV fixture
|
||||
csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
{:ok, conn: conn, admin_user: admin_user, csv_content: csv_content}
|
||||
end
|
||||
|
||||
test "admin can upload CSV and start import", %{conn: conn, csv_content: csv_content} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content)
|
||||
|
||||
# Trigger start_import event via form submit
|
||||
assert view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Check that import has started or shows appropriate message
|
||||
html = render(view)
|
||||
# Either import started successfully OR we see a specific error (not admin error)
|
||||
import_started = html =~ "Import in progress" or html =~ "running" or html =~ "progress"
|
||||
no_admin_error = not (html =~ "Only administrators can import")
|
||||
# If import failed, it should be a CSV parsing error, not an admin error
|
||||
if html =~ "Failed to prepare CSV import" do
|
||||
# This is acceptable - CSV might have issues, but admin check passed
|
||||
assert no_admin_error
|
||||
else
|
||||
# Import should have started
|
||||
assert import_started or html =~ "CSV File"
|
||||
end
|
||||
end
|
||||
|
||||
test "admin import initializes progress correctly", %{conn: conn, csv_content: csv_content} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content)
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Check that import has started or shows appropriate message
|
||||
html = render(view)
|
||||
# Either import started successfully OR we see a specific error (not admin error)
|
||||
import_started = html =~ "Import in progress" or html =~ "running" or html =~ "progress"
|
||||
no_admin_error = not (html =~ "Only administrators can import")
|
||||
# If import failed, it should be a CSV parsing error, not an admin error
|
||||
if html =~ "Failed to prepare CSV import" do
|
||||
# This is acceptable - CSV might have issues, but admin check passed
|
||||
assert no_admin_error
|
||||
else
|
||||
# Import should have started
|
||||
assert import_started or html =~ "CSV File"
|
||||
end
|
||||
end
|
||||
|
||||
test "non-admin cannot start import", %{conn: conn} do
|
||||
# Member (own_data) is redirected when accessing /settings (no page permission)
|
||||
member_user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user)
|
||||
|
||||
assert {:error, {:redirect, %{to: to}}} = live(conn, ~p"/settings")
|
||||
assert to == "/users/#{member_user.id}"
|
||||
end
|
||||
|
||||
test "invalid CSV shows user-friendly error", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Create invalid CSV (missing required fields)
|
||||
invalid_csv = "invalid_header\nincomplete_row"
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, invalid_csv, "invalid.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Check for error message (flash)
|
||||
html = render(view)
|
||||
assert html =~ "error" or html =~ "failed" or html =~ "Failed to prepare"
|
||||
end
|
||||
|
||||
@tag :skip
|
||||
test "empty CSV shows error", %{conn: conn} do
|
||||
# Skip this test - Phoenix LiveView has issues with empty file uploads in tests
|
||||
# The error is handled correctly in production, but test framework has limitations
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
empty_csv = " "
|
||||
csv_path = Path.join([System.tmp_dir!(), "empty_#{System.unique_integer()}.csv"])
|
||||
File.write!(csv_path, empty_csv)
|
||||
|
||||
view
|
||||
|> file_input("#csv-upload-form", :csv_file, [
|
||||
%{
|
||||
last_modified: System.system_time(:second),
|
||||
name: "empty.csv",
|
||||
content: empty_csv,
|
||||
size: byte_size(empty_csv),
|
||||
type: "text/csv"
|
||||
}
|
||||
])
|
||||
|> render_upload("empty.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Check for error message
|
||||
html = render(view)
|
||||
assert html =~ "error" or html =~ "empty" or html =~ "failed" or html =~ "Failed to prepare"
|
||||
end
|
||||
end
|
||||
|
||||
describe "CSV Import - Step 3: Chunk Processing" do
|
||||
setup %{conn: conn} do
|
||||
# Ensure admin user
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
|
||||
|
||||
# Read valid CSV fixture
|
||||
valid_csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
# Read invalid CSV fixture
|
||||
invalid_csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "invalid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
{:ok,
|
||||
conn: conn,
|
||||
admin_user: admin_user,
|
||||
valid_csv_content: valid_csv_content,
|
||||
invalid_csv_content: invalid_csv_content}
|
||||
end
|
||||
|
||||
test "happy path: valid CSV processes all chunks and shows done status", %{
|
||||
conn: conn,
|
||||
valid_csv_content: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content)
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for processing to complete
|
||||
# In test mode, chunks are processed synchronously and messages are sent via send/2
|
||||
# render(view) processes handle_info messages, so we call it multiple times
|
||||
# to ensure all messages are processed
|
||||
# Use the same approach as "success rendering" test which works
|
||||
Process.sleep(1000)
|
||||
|
||||
html = render(view)
|
||||
# Should show success count (inserted count)
|
||||
assert html =~ "Inserted" or html =~ "inserted" or html =~ "2"
|
||||
# Should show completed status
|
||||
assert html =~ "completed" or html =~ "done" or html =~ "Import completed" or
|
||||
has_element?(view, "[data-testid='import-results-panel']")
|
||||
end
|
||||
|
||||
test "error handling: invalid CSV shows errors with line numbers", %{
|
||||
conn: conn,
|
||||
invalid_csv_content: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content, "invalid_import.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for chunk processing
|
||||
Process.sleep(500)
|
||||
|
||||
html = render(view)
|
||||
# Should show failure count > 0
|
||||
assert html =~ "failed" or html =~ "error" or html =~ "Failed"
|
||||
|
||||
# Should show line numbers in errors (from service, not recalculated)
|
||||
# Line numbers should be 2, 3 (header is line 1)
|
||||
assert html =~ "2" or html =~ "3" or html =~ "line"
|
||||
end
|
||||
|
||||
test "error cap: many failing rows caps errors at 50", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Generate CSV with 100 invalid rows (all missing email)
|
||||
header = "first_name;last_name;email;street;postal_code;city\n"
|
||||
invalid_rows = for i <- 1..100, do: "Row#{i};Last#{i};;Street#{i};12345;City#{i}\n"
|
||||
large_invalid_csv = header <> Enum.join(invalid_rows)
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, large_invalid_csv, "large_invalid.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for chunk processing
|
||||
Process.sleep(1000)
|
||||
|
||||
html = render(view)
|
||||
# Should show failed count == 100
|
||||
assert html =~ "100" or html =~ "failed"
|
||||
|
||||
# Errors should be capped at 50 (but we can't easily check exact count in HTML)
|
||||
# The important thing is that processing completes without crashing
|
||||
assert html =~ "done" or html =~ "complete" or html =~ "finished"
|
||||
end
|
||||
|
||||
test "chunk scheduling: progress updates show chunk processing", %{
|
||||
conn: conn,
|
||||
valid_csv_content: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content)
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait a bit for processing to start
|
||||
Process.sleep(200)
|
||||
|
||||
# Check that status area exists (with aria-live for accessibility)
|
||||
html = render(view)
|
||||
|
||||
assert html =~ "aria-live" or html =~ "status" or html =~ "progress" or
|
||||
html =~ "Processing" or html =~ "chunk"
|
||||
|
||||
# Final state should be :done
|
||||
Process.sleep(500)
|
||||
final_html = render(view)
|
||||
assert final_html =~ "done" or final_html =~ "complete" or final_html =~ "finished"
|
||||
end
|
||||
end
|
||||
|
||||
describe "CSV Import - Step 4: Results UI" do
|
||||
setup %{conn: conn} do
|
||||
# Ensure admin user
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
|
||||
|
||||
# Read valid CSV fixture
|
||||
valid_csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
# Read invalid CSV fixture
|
||||
invalid_csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "invalid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
# Read CSV with unknown custom field
|
||||
unknown_custom_field_csv =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_unknown_custom_field.csv"])
|
||||
|> File.read!()
|
||||
|
||||
{:ok,
|
||||
conn: conn,
|
||||
admin_user: admin_user,
|
||||
valid_csv_content: valid_csv_content,
|
||||
invalid_csv_content: invalid_csv_content,
|
||||
unknown_custom_field_csv: unknown_custom_field_csv}
|
||||
end
|
||||
|
||||
test "success rendering: valid CSV shows success count", %{
|
||||
conn: conn,
|
||||
valid_csv_content: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content)
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for processing to complete
|
||||
Process.sleep(1000)
|
||||
|
||||
html = render(view)
|
||||
# Should show success count (inserted count)
|
||||
assert html =~ "Inserted" or html =~ "inserted" or html =~ "2"
|
||||
# Should show completed status
|
||||
assert html =~ "completed" or html =~ "done" or html =~ "Import completed"
|
||||
end
|
||||
|
||||
test "error rendering: invalid CSV shows failure count and error list with line numbers", %{
|
||||
conn: conn,
|
||||
invalid_csv_content: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content, "invalid_import.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for processing
|
||||
Process.sleep(1000)
|
||||
|
||||
html = render(view)
|
||||
# Should show failure count
|
||||
assert html =~ "Failed" or html =~ "failed"
|
||||
|
||||
# Should show error list with line numbers (from service, not recalculated)
|
||||
assert html =~ "Line" or html =~ "line" or html =~ "2" or html =~ "3"
|
||||
# Should show error messages
|
||||
assert html =~ "error" or html =~ "Error" or html =~ "Errors"
|
||||
end
|
||||
|
||||
test "warning rendering: CSV with unknown custom field shows warnings block", %{
|
||||
conn: conn,
|
||||
unknown_custom_field_csv: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
csv_path =
|
||||
Path.join([System.tmp_dir!(), "unknown_custom_#{System.unique_integer()}.csv"])
|
||||
|
||||
File.write!(csv_path, csv_content)
|
||||
|
||||
view
|
||||
|> file_input("#csv-upload-form", :csv_file, [
|
||||
%{
|
||||
last_modified: System.system_time(:second),
|
||||
name: "unknown_custom.csv",
|
||||
content: csv_content,
|
||||
size: byte_size(csv_content),
|
||||
type: "text/csv"
|
||||
}
|
||||
])
|
||||
|> render_upload("unknown_custom.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for processing
|
||||
Process.sleep(1000)
|
||||
|
||||
html = render(view)
|
||||
# Should show warnings block (if warnings were generated)
|
||||
# Warnings are generated when unknown custom field columns are detected
|
||||
# Check if warnings section exists OR if import completed successfully
|
||||
has_warnings = html =~ "Warning" or html =~ "warning" or html =~ "Warnings"
|
||||
import_completed = html =~ "completed" or html =~ "done" or html =~ "Import Results"
|
||||
|
||||
# If warnings exist, they should contain the column name
|
||||
if has_warnings do
|
||||
assert html =~ "UnknownCustomField" or html =~ "unknown" or html =~ "Unknown column" or
|
||||
html =~ "will be ignored"
|
||||
end
|
||||
|
||||
# Import should complete (either with or without warnings)
|
||||
assert import_completed
|
||||
end
|
||||
|
||||
test "A11y: file input has label", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/settings")
|
||||
|
||||
# Check for label associated with file input
|
||||
assert html =~ ~r/<label[^>]*for=["']csv_file["']/i or
|
||||
html =~ ~r/<label[^>]*>.*CSV File/i
|
||||
end
|
||||
|
||||
test "A11y: status/progress container has aria-live", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
html = render(view)
|
||||
# Check for aria-live attribute in status area
|
||||
assert html =~ ~r/aria-live=["']polite["']/i
|
||||
end
|
||||
|
||||
test "A11y: links have descriptive text", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/settings")
|
||||
|
||||
# Check that links have descriptive text (not just "click here")
|
||||
# Template links should have text like "English Template" or "German Template"
|
||||
assert html =~ "English Template" or html =~ "German Template" or
|
||||
html =~ "English" or html =~ "German"
|
||||
|
||||
# Custom Fields section should have descriptive text (Data Field button)
|
||||
# The component uses "New Data Field" button, not a link
|
||||
assert html =~ "Data Field" or html =~ "New Data Field"
|
||||
end
|
||||
end
|
||||
|
||||
describe "CSV Import - Step 5: Edge Cases" do
|
||||
setup %{conn: conn} do
|
||||
# Ensure admin user
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
|
||||
|
||||
{:ok, conn: conn, admin_user: admin_user}
|
||||
end
|
||||
|
||||
test "BOM + semicolon delimiter: import succeeds", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Read CSV with BOM
|
||||
csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_bom_semicolon.csv"])
|
||||
|> File.read!()
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content, "bom_import.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for processing
|
||||
Process.sleep(1000)
|
||||
|
||||
html = render(view)
|
||||
# Should succeed (BOM is stripped automatically)
|
||||
assert html =~ "completed" or html =~ "done" or html =~ "Inserted"
|
||||
# Should not show error about BOM
|
||||
refute html =~ "BOM" or html =~ "encoding"
|
||||
end
|
||||
|
||||
test "empty lines: line numbers in errors correspond to physical CSV lines", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# CSV with empty line: header (line 1), valid row (line 2), empty (line 3), invalid (line 4)
|
||||
csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_empty_lines.csv"])
|
||||
|> File.read!()
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content, "empty_lines.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for processing
|
||||
Process.sleep(1000)
|
||||
|
||||
html = render(view)
|
||||
# Should show error with correct line number (line 4, not line 3)
|
||||
# The error should be on the line with invalid email, which is after the empty line
|
||||
assert html =~ "Line 4" or html =~ "line 4" or html =~ "4"
|
||||
# Should show error message
|
||||
assert html =~ "error" or html =~ "Error" or html =~ "invalid"
|
||||
end
|
||||
|
||||
test "too many rows (1001): import is rejected with user-friendly error", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Generate CSV with 1001 rows dynamically
|
||||
header = "first_name;last_name;email;street;postal_code;city\n"
|
||||
|
||||
rows =
|
||||
for i <- 1..1001 do
|
||||
"Row#{i};Last#{i};email#{i}@example.com;Street#{i};12345;City#{i}\n"
|
||||
end
|
||||
|
||||
large_csv = header <> Enum.join(rows)
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, large_csv, "too_many_rows.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
html = render(view)
|
||||
# Should show user-friendly error about row limit
|
||||
assert html =~ "exceeds" or html =~ "maximum" or html =~ "limit" or html =~ "1000" or
|
||||
html =~ "Failed to prepare"
|
||||
end
|
||||
|
||||
test "wrong file type (.txt): upload shows error", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Create .txt file (not .csv)
|
||||
txt_content = "This is not a CSV file\nJust some text\n"
|
||||
txt_path = Path.join([System.tmp_dir!(), "wrong_type_#{System.unique_integer()}.txt"])
|
||||
File.write!(txt_path, txt_content)
|
||||
|
||||
# Try to upload .txt file
|
||||
# Note: allow_upload is configured to accept only .csv, so this should fail
|
||||
# In tests, we can't easily simulate file type rejection, but we can check
|
||||
# that the UI shows appropriate help text
|
||||
html = render(view)
|
||||
# Should show CSV-only restriction in help text
|
||||
assert html =~ "CSV" or html =~ "csv" or html =~ ".csv"
|
||||
end
|
||||
|
||||
test "file input has correct accept attribute for CSV only", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/settings")
|
||||
|
||||
# Check that file input has accept attribute for CSV
|
||||
assert html =~ ~r/accept=["'][^"']*csv["']/i or html =~ "CSV files only"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
282
test/mv_web/live/import_export_live_test.exs
Normal file
282
test/mv_web/live/import_export_live_test.exs
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
defmodule MvWeb.ImportExportLiveTest do
|
||||
@moduledoc """
|
||||
Tests for Import/Export LiveView: authorization (business rule), CSV import integration,
|
||||
and minimal UI smoke tests. CSV parsing/validation logic is covered by
|
||||
Mv.Membership.Import.MemberCSVTest; here we verify access control and end-to-end outcomes.
|
||||
"""
|
||||
use MvWeb.ConnCase, async: true
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Membership
|
||||
|
||||
defp put_locale_en(conn), do: Plug.Conn.put_session(conn, "locale", "en")
|
||||
|
||||
defp upload_csv_file(view, csv_content, filename \\ "test_import.csv") do
|
||||
view
|
||||
|> file_input("#csv-upload-form", :csv_file, [
|
||||
%{
|
||||
last_modified: System.system_time(:second),
|
||||
name: filename,
|
||||
content: csv_content,
|
||||
size: byte_size(csv_content),
|
||||
type: "text/csv"
|
||||
}
|
||||
])
|
||||
|> render_upload(filename)
|
||||
end
|
||||
|
||||
defp submit_import(view), do: view |> form("#csv-upload-form", %{}) |> render_submit()
|
||||
|
||||
defp wait_for_import_completion, do: Process.sleep(1000)
|
||||
|
||||
# ---------- Business logic: Authorization ----------
|
||||
describe "Authorization" do
|
||||
test "non-admin user cannot access import/export page and sees permission error", %{
|
||||
conn: conn
|
||||
} do
|
||||
member_user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> MvWeb.ConnCase.conn_with_password_user(member_user)
|
||||
|> put_locale_en()
|
||||
|
||||
assert {:error, {:redirect, %{to: redirect_path, flash: %{"error" => msg}}}} =
|
||||
live(conn, ~p"/admin/import-export")
|
||||
|
||||
assert redirect_path =~ "/users/"
|
||||
assert msg =~ "don't have permission"
|
||||
end
|
||||
|
||||
test "admin user can access page and run import", %{conn: conn} do
|
||||
conn = put_locale_en(conn)
|
||||
|
||||
csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
upload_csv_file(view, csv_content)
|
||||
submit_import(view)
|
||||
wait_for_import_completion()
|
||||
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
assert has_element?(view, "[data-testid='import-summary']")
|
||||
html = render(view)
|
||||
refute html =~ "Import aborted"
|
||||
assert html =~ "Successfully inserted"
|
||||
|
||||
# Business outcome: two members from fixture were created
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
{:ok, members} = Membership.list_members(actor: system_actor)
|
||||
|
||||
imported =
|
||||
Enum.filter(members, fn m ->
|
||||
m.email in ["alice.smith@example.com", "bob.johnson@example.com"]
|
||||
end)
|
||||
|
||||
assert length(imported) == 2
|
||||
end
|
||||
end
|
||||
|
||||
# ---------- Business logic: Import behaviour (integration) ----------
|
||||
describe "CSV Import - integration" do
|
||||
setup %{conn: conn} do
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> MvWeb.ConnCase.conn_with_password_user(admin_user)
|
||||
|> put_locale_en()
|
||||
|
||||
valid_csv =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
invalid_csv =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "invalid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
unknown_cf_csv =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_unknown_custom_field.csv"])
|
||||
|> File.read!()
|
||||
|
||||
{:ok,
|
||||
conn: conn,
|
||||
valid_csv: valid_csv,
|
||||
invalid_csv: invalid_csv,
|
||||
unknown_custom_field_csv: unknown_cf_csv}
|
||||
end
|
||||
|
||||
test "invalid CSV shows user-friendly prepare error", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
upload_csv_file(view, "invalid_header\nincomplete_row", "invalid.csv")
|
||||
submit_import(view)
|
||||
html = render(view)
|
||||
assert html =~ "Failed to prepare CSV import"
|
||||
end
|
||||
|
||||
test "invalid rows show errors with correct CSV line numbers", %{
|
||||
conn: conn,
|
||||
invalid_csv: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
upload_csv_file(view, csv_content, "invalid_import.csv")
|
||||
submit_import(view)
|
||||
wait_for_import_completion()
|
||||
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
assert has_element?(view, "[data-testid='import-error-list']")
|
||||
html = render(view)
|
||||
assert html =~ "Failed"
|
||||
# Fixture has invalid email on line 2 and missing email on line 3
|
||||
assert html =~ "Line 2"
|
||||
assert html =~ "Line 3"
|
||||
end
|
||||
|
||||
test "error list is capped and truncation message is shown", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
header = "first_name;last_name;email;street;postal_code;city\n"
|
||||
|
||||
invalid_rows =
|
||||
for i <- 1..100, do: "Row#{i};Last#{i};;Street#{i};12345;City#{i}\n"
|
||||
|
||||
upload_csv_file(view, header <> Enum.join(invalid_rows), "large_invalid.csv")
|
||||
submit_import(view)
|
||||
wait_for_import_completion()
|
||||
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
assert has_element?(view, "[data-testid='import-error-list']")
|
||||
html = render(view)
|
||||
assert html =~ "100"
|
||||
assert html =~ "Error list truncated"
|
||||
end
|
||||
|
||||
test "row limit is enforced (1001 rows rejected)", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
header = "first_name;last_name;email;street;postal_code;city\n"
|
||||
|
||||
rows =
|
||||
for i <- 1..1001 do
|
||||
"Row#{i};Last#{i};email#{i}@example.com;Street#{i};12345;City#{i}\n"
|
||||
end
|
||||
|
||||
upload_csv_file(view, header <> Enum.join(rows), "too_many_rows.csv")
|
||||
submit_import(view)
|
||||
html = render(view)
|
||||
assert html =~ "exceeds"
|
||||
end
|
||||
|
||||
test "BOM and semicolon delimiter are accepted", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_bom_semicolon.csv"])
|
||||
|> File.read!()
|
||||
|
||||
upload_csv_file(view, csv_content, "bom_import.csv")
|
||||
submit_import(view)
|
||||
wait_for_import_completion()
|
||||
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
html = render(view)
|
||||
assert html =~ "Successfully inserted"
|
||||
refute html =~ "BOM"
|
||||
end
|
||||
|
||||
test "physical line numbers in errors (empty line does not shift numbering)", %{
|
||||
conn: conn
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_empty_lines.csv"])
|
||||
|> File.read!()
|
||||
|
||||
upload_csv_file(view, csv_content, "empty_lines.csv")
|
||||
submit_import(view)
|
||||
wait_for_import_completion()
|
||||
|
||||
assert has_element?(view, "[data-testid='import-error-list']")
|
||||
html = render(view)
|
||||
# Invalid row is on physical line 4 (header, valid row, empty line, then invalid)
|
||||
assert html =~ "Line 4"
|
||||
end
|
||||
|
||||
test "unknown custom field column produces warnings", %{
|
||||
conn: conn,
|
||||
unknown_custom_field_csv: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
upload_csv_file(view, csv_content, "unknown_custom.csv")
|
||||
submit_import(view)
|
||||
wait_for_import_completion()
|
||||
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
assert has_element?(view, "[data-testid='import-warnings']")
|
||||
html = render(view)
|
||||
assert html =~ "Warnings"
|
||||
end
|
||||
end
|
||||
|
||||
# ---------- UI (smoke / framework): tagged for exclusion from fast CI ----------
|
||||
describe "Import/Export page UI" do
|
||||
@describetag :ui
|
||||
setup %{conn: conn} do
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> MvWeb.ConnCase.conn_with_password_user(admin_user)
|
||||
|> put_locale_en()
|
||||
|
||||
{:ok, conn: conn}
|
||||
end
|
||||
|
||||
test "page loads and shows import form and export placeholder", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
assert has_element?(view, "[data-testid='csv-upload-form']")
|
||||
assert has_element?(view, "[data-testid='start-import-button']")
|
||||
assert has_element?(view, "[data-testid='custom-fields-link']")
|
||||
html = render(view)
|
||||
assert html =~ "Import Members (CSV)"
|
||||
assert html =~ "Export Members (CSV)"
|
||||
assert html =~ "Export functionality will be available"
|
||||
end
|
||||
|
||||
test "template links and file input are present", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
assert has_element?(view, "a[href*='/templates/member_import_en.csv']")
|
||||
assert has_element?(view, "a[href*='/templates/member_import_de.csv']")
|
||||
assert has_element?(view, "label[for='csv_file']")
|
||||
assert has_element?(view, "#csv_file_help")
|
||||
assert has_element?(view, "[data-testid='csv-upload-form'] input[type='file']")
|
||||
end
|
||||
|
||||
test "after successful import, progress container has aria-live", %{conn: conn} do
|
||||
csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
upload_csv_file(view, csv_content)
|
||||
submit_import(view)
|
||||
wait_for_import_completion()
|
||||
assert has_element?(view, "[data-testid='import-progress-container']")
|
||||
html = render(view)
|
||||
assert html =~ "aria-live"
|
||||
end
|
||||
end
|
||||
|
||||
# Skip: LiveView test harness does not reliably support empty/minimal file uploads.
|
||||
# See docs/csv-member-import-v1.md (Issue #9).
|
||||
@tag :skip
|
||||
test "empty CSV shows error", %{conn: conn} do
|
||||
conn = put_locale_en(conn)
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
upload_csv_file(view, " ", "empty.csv")
|
||||
submit_import(view)
|
||||
html = render(view)
|
||||
assert html =~ "Failed to prepare"
|
||||
end
|
||||
end
|
||||
102
test/mv_web/live/member_live_authorization_test.exs
Normal file
102
test/mv_web/live/member_live_authorization_test.exs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
defmodule MvWeb.MemberLiveAuthorizationTest do
|
||||
@moduledoc """
|
||||
Tests for UI authorization on Member LiveViews (Index and Show).
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Fixtures
|
||||
|
||||
describe "Member Index - Vorstand (read_only)" do
|
||||
@tag role: :read_only
|
||||
test "sees member list but not New Member button", %{conn: conn} do
|
||||
_member = Fixtures.member_fixture()
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
refute has_element?(view, "[data-testid=member-new]")
|
||||
end
|
||||
|
||||
@tag role: :read_only
|
||||
test "does not see Edit or Delete buttons in table", %{conn: conn} do
|
||||
member = Fixtures.member_fixture()
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
refute has_element?(view, "#row-#{member.id} [data-testid=member-edit]")
|
||||
refute has_element?(view, "#row-#{member.id} [data-testid=member-delete]")
|
||||
end
|
||||
end
|
||||
|
||||
describe "Member Index - Kassenwart (normal_user)" do
|
||||
@tag role: :normal_user
|
||||
test "sees New Member and Edit buttons", %{conn: conn} do
|
||||
member = Fixtures.member_fixture()
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
assert has_element?(view, "[data-testid=member-new]")
|
||||
assert has_element?(view, "#row-#{member.id} [data-testid=member-edit]")
|
||||
end
|
||||
|
||||
@tag role: :normal_user
|
||||
test "does not see Delete button", %{conn: conn} do
|
||||
member = Fixtures.member_fixture()
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
refute has_element?(view, "#row-#{member.id} [data-testid=member-delete]")
|
||||
end
|
||||
end
|
||||
|
||||
describe "Member Index - Admin" do
|
||||
@tag role: :admin
|
||||
test "sees New Member, Edit and Delete buttons", %{conn: conn} do
|
||||
member = Fixtures.member_fixture()
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
assert has_element?(view, "[data-testid=member-new]")
|
||||
assert has_element?(view, "#row-#{member.id} [data-testid=member-edit]")
|
||||
assert has_element?(view, "#row-#{member.id} [data-testid=member-delete]")
|
||||
end
|
||||
end
|
||||
|
||||
describe "Member Index - Mitglied (own_data)" do
|
||||
@tag role: :member
|
||||
test "is redirected when accessing /members", %{conn: conn, current_user: user} do
|
||||
assert {:error, {:redirect, %{to: to}}} = live(conn, "/members")
|
||||
assert to == "/users/#{user.id}"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Member Show - Edit button visibility" do
|
||||
@tag role: :admin
|
||||
test "admin sees Edit button", %{conn: conn} do
|
||||
member = Fixtures.member_fixture()
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
assert has_element?(view, "[data-testid=member-edit]")
|
||||
end
|
||||
|
||||
@tag role: :read_only
|
||||
test "read_only does not see Edit button", %{conn: conn} do
|
||||
member = Fixtures.member_fixture()
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
refute has_element?(view, "[data-testid=member-edit]")
|
||||
end
|
||||
|
||||
@tag role: :normal_user
|
||||
test "normal_user sees Edit button", %{conn: conn} do
|
||||
member = Fixtures.member_fixture()
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
assert has_element?(view, "[data-testid=member-edit]")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -50,7 +50,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|
|||
end
|
||||
|
||||
describe "create form" do
|
||||
test "creates new membership fee type", %{conn: conn} do
|
||||
test "creates new membership fee type", %{conn: conn, user: user} do
|
||||
{:ok, view, _html} = live(conn, "/membership_fee_types/new")
|
||||
|
||||
form_data = %{
|
||||
|
|
@ -67,12 +67,13 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|
|||
|
||||
assert to == "/membership_fee_types"
|
||||
|
||||
# Verify type was created
|
||||
# Verify type was created (use actor so read is authorized)
|
||||
type =
|
||||
MembershipFeeType
|
||||
|> Ash.Query.filter(name == "New Type")
|
||||
|> Ash.read_one!()
|
||||
|> Ash.read_one!(domain: Mv.MembershipFees, actor: user)
|
||||
|
||||
assert type != nil, "Expected membership fee type to be created"
|
||||
assert type.amount == Decimal.new("75.00")
|
||||
assert type.interval == :yearly
|
||||
end
|
||||
|
|
@ -140,7 +141,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|
|||
assert html =~ "3" || html =~ "members" || html =~ "Mitglieder"
|
||||
end
|
||||
|
||||
test "amount change can be confirmed", %{conn: conn} do
|
||||
test "amount change can be confirmed", %{conn: conn, user: user} do
|
||||
fee_type = create_fee_type(%{amount: Decimal.new("50.00")})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit")
|
||||
|
|
@ -159,12 +160,17 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|
|||
|> form("#membership-fee-type-form", %{"membership_fee_type[amount]" => "75.00"})
|
||||
|> render_submit()
|
||||
|
||||
# Amount should be updated
|
||||
updated_type = Ash.read_one!(MembershipFeeType |> Ash.Query.filter(id == ^fee_type.id))
|
||||
# Amount should be updated (use actor so read is authorized)
|
||||
updated_type =
|
||||
MembershipFeeType
|
||||
|> Ash.Query.filter(id == ^fee_type.id)
|
||||
|> Ash.read_one!(domain: Mv.MembershipFees, actor: user)
|
||||
|
||||
assert updated_type != nil
|
||||
assert updated_type.amount == Decimal.new("75.00")
|
||||
end
|
||||
|
||||
test "amount change can be cancelled", %{conn: conn} do
|
||||
test "amount change can be cancelled", %{conn: conn, user: user} do
|
||||
fee_type = create_fee_type(%{amount: Decimal.new("50.00")})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit")
|
||||
|
|
@ -178,8 +184,13 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|
|||
|> element("button[phx-click='cancel_amount_change']")
|
||||
|> render_click()
|
||||
|
||||
# Amount should remain unchanged
|
||||
updated_type = Ash.read_one!(MembershipFeeType |> Ash.Query.filter(id == ^fee_type.id))
|
||||
# Amount should remain unchanged (use actor so read is authorized)
|
||||
updated_type =
|
||||
MembershipFeeType
|
||||
|> Ash.Query.filter(id == ^fee_type.id)
|
||||
|> Ash.read_one!(domain: Mv.MembershipFees, actor: user)
|
||||
|
||||
assert updated_type != nil
|
||||
assert updated_type.amount == Decimal.new("50.00")
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ defmodule MvWeb.ProfileNavigationTest do
|
|||
end
|
||||
|
||||
@tag :skip
|
||||
# credo:disable-for-next-line Credo.Check.Design.TagTODO
|
||||
# TODO: Implement user initials in navbar avatar - see issue #170
|
||||
test "shows user initials in avatar", %{conn: conn} do
|
||||
# Setup: Create and login a user
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ defmodule MvWeb.RoleLive.ShowTest do
|
|||
alias Mv.Authorization
|
||||
alias Mv.Authorization.Role
|
||||
|
||||
# Helper to create a role
|
||||
# Helper to create a role (authorize?: false for test data setup)
|
||||
defp create_role(attrs \\ %{}) do
|
||||
default_attrs = %{
|
||||
name: "Test Role #{System.unique_integer([:positive])}",
|
||||
|
|
@ -28,7 +28,7 @@ defmodule MvWeb.RoleLive.ShowTest do
|
|||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
case Authorization.create_role(attrs) do
|
||||
case Authorization.create_role(attrs, authorize?: false) do
|
||||
{:ok, role} -> role
|
||||
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
|
||||
end
|
||||
|
|
@ -38,7 +38,7 @@ defmodule MvWeb.RoleLive.ShowTest do
|
|||
defp create_admin_user(conn, actor) do
|
||||
# Create admin role
|
||||
admin_role =
|
||||
case Authorization.list_roles() do
|
||||
case Authorization.list_roles(authorize?: false) do
|
||||
{:ok, roles} ->
|
||||
case Enum.find(roles, &(&1.name == "Admin")) do
|
||||
nil ->
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ defmodule MvWeb.RoleLiveTest do
|
|||
alias Mv.Authorization
|
||||
alias Mv.Authorization.Role
|
||||
|
||||
# Helper to create a role
|
||||
# Helper to create a role (authorize?: false for test data setup)
|
||||
defp create_role(attrs \\ %{}) do
|
||||
default_attrs = %{
|
||||
name: "Test Role #{System.unique_integer([:positive])}",
|
||||
|
|
@ -19,7 +19,7 @@ defmodule MvWeb.RoleLiveTest do
|
|||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
case Authorization.create_role(attrs) do
|
||||
case Authorization.create_role(attrs, authorize?: false) do
|
||||
{:ok, role} -> role
|
||||
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
|
||||
end
|
||||
|
|
@ -29,7 +29,7 @@ defmodule MvWeb.RoleLiveTest do
|
|||
defp create_admin_user(conn, actor) do
|
||||
# Create admin role
|
||||
admin_role =
|
||||
case Authorization.list_roles() do
|
||||
case Authorization.list_roles(authorize?: false) do
|
||||
{:ok, roles} ->
|
||||
case Enum.find(roles, &(&1.name == "Admin")) do
|
||||
nil ->
|
||||
|
|
@ -332,7 +332,7 @@ defmodule MvWeb.RoleLiveTest do
|
|||
assert match?({:error, {:redirect, %{to: "/admin/roles"}}}, result)
|
||||
end
|
||||
|
||||
test "updates role name", %{conn: conn, role: role} do
|
||||
test "updates role name", %{conn: conn, role: role, actor: actor} do
|
||||
{:ok, view, _html} = live(conn, "/admin/roles/#{role.id}/edit?return_to=show")
|
||||
|
||||
attrs = %{
|
||||
|
|
@ -348,7 +348,7 @@ defmodule MvWeb.RoleLiveTest do
|
|||
assert_redirect(view, "/admin/roles/#{role.id}")
|
||||
|
||||
# Verify update
|
||||
{:ok, updated_role} = Authorization.get_role(role.id)
|
||||
{:ok, updated_role} = Authorization.get_role(role.id, actor: actor)
|
||||
assert updated_role.name == "Updated Role Name"
|
||||
end
|
||||
|
||||
|
|
@ -377,7 +377,7 @@ defmodule MvWeb.RoleLiveTest do
|
|||
assert_redirect(view, "/admin/roles/#{system_role.id}")
|
||||
|
||||
# Verify update
|
||||
{:ok, updated_role} = Authorization.get_role(system_role.id)
|
||||
{:ok, updated_role} = Authorization.get_role(system_role.id, actor: actor)
|
||||
assert updated_role.permission_set_name == "read_only"
|
||||
end
|
||||
end
|
||||
|
|
@ -390,7 +390,7 @@ defmodule MvWeb.RoleLiveTest do
|
|||
end
|
||||
|
||||
@tag :slow
|
||||
test "deletes non-system role", %{conn: conn} do
|
||||
test "deletes non-system role", %{conn: conn, actor: actor} do
|
||||
role = create_role()
|
||||
|
||||
{:ok, view, html} = live(conn, "/admin/roles")
|
||||
|
|
@ -404,7 +404,7 @@ defmodule MvWeb.RoleLiveTest do
|
|||
|
||||
# Verify deletion by checking database
|
||||
assert {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{}]}} =
|
||||
Authorization.get_role(role.id)
|
||||
Authorization.get_role(role.id, actor: actor)
|
||||
end
|
||||
|
||||
test "fails to delete system role with error message", %{conn: conn, actor: actor} do
|
||||
|
|
@ -430,7 +430,7 @@ defmodule MvWeb.RoleLiveTest do
|
|||
assert render(view) =~ "System roles cannot be deleted"
|
||||
|
||||
# Role should still exist
|
||||
{:ok, _role} = Authorization.get_role(system_role.id)
|
||||
{:ok, _role} = Authorization.get_role(system_role.id, actor: actor)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
81
test/mv_web/live/user_live_authorization_test.exs
Normal file
81
test/mv_web/live/user_live_authorization_test.exs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
defmodule MvWeb.UserLiveAuthorizationTest do
|
||||
@moduledoc """
|
||||
Tests for UI authorization on User LiveViews (Index and Show).
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Fixtures
|
||||
|
||||
describe "User Index - Admin" do
|
||||
@tag role: :admin
|
||||
test "sees New User, Edit and Delete buttons", %{conn: conn} do
|
||||
user = Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
{:ok, view, _html} = live(conn, "/users")
|
||||
|
||||
assert has_element?(view, "[data-testid=user-new]")
|
||||
assert has_element?(view, "#row-#{user.id} [data-testid=user-edit]")
|
||||
assert has_element?(view, "#row-#{user.id} [data-testid=user-delete]")
|
||||
end
|
||||
end
|
||||
|
||||
describe "User Index - Non-Admin is redirected" do
|
||||
@tag role: :read_only
|
||||
test "read_only is redirected when accessing /users", %{conn: conn, current_user: user} do
|
||||
assert {:error, {:redirect, %{to: to}}} = live(conn, "/users")
|
||||
assert to == "/users/#{user.id}"
|
||||
end
|
||||
|
||||
@tag role: :member
|
||||
test "member is redirected when accessing /users", %{conn: conn, current_user: user} do
|
||||
assert {:error, {:redirect, %{to: to}}} = live(conn, "/users")
|
||||
assert to == "/users/#{user.id}"
|
||||
end
|
||||
|
||||
@tag role: :normal_user
|
||||
test "normal_user is redirected when accessing /users", %{conn: conn, current_user: user} do
|
||||
assert {:error, {:redirect, %{to: to}}} = live(conn, "/users")
|
||||
assert to == "/users/#{user.id}"
|
||||
end
|
||||
end
|
||||
|
||||
describe "User Show - own profile" do
|
||||
@tag role: :member
|
||||
test "member sees Edit button on own profile", %{conn: conn, current_user: user} do
|
||||
{:ok, view, _html} = live(conn, "/users/#{user.id}")
|
||||
|
||||
assert has_element?(view, "[data-testid=user-edit]")
|
||||
end
|
||||
|
||||
@tag role: :read_only
|
||||
test "read_only sees Edit button on own profile", %{conn: conn, current_user: user} do
|
||||
{:ok, view, _html} = live(conn, "/users/#{user.id}")
|
||||
|
||||
assert has_element?(view, "[data-testid=user-edit]")
|
||||
end
|
||||
|
||||
@tag role: :admin
|
||||
test "admin sees Edit button on user show", %{conn: conn} do
|
||||
user = Fixtures.user_with_role_fixture("read_only")
|
||||
|
||||
{:ok, view, _html} = live(conn, "/users/#{user.id}")
|
||||
|
||||
assert has_element?(view, "[data-testid=user-edit]")
|
||||
end
|
||||
end
|
||||
|
||||
describe "User Show - other user (non-admin redirected)" do
|
||||
@tag role: :member
|
||||
test "member is redirected when accessing other user's profile", %{
|
||||
conn: conn,
|
||||
current_user: current_user
|
||||
} do
|
||||
other_user = Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
assert {:error, {:redirect, %{to: to}}} = live(conn, "/users/#{other_user.id}")
|
||||
assert to == "/users/#{current_user.id}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -9,6 +9,7 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
|
|||
require Ash.Query
|
||||
|
||||
describe "error handling - flash messages" do
|
||||
@describetag :ui
|
||||
test "shows flash message when member creation fails with validation error", %{conn: conn} do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
|
|
|
|||
|
|
@ -127,10 +127,12 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
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])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> 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
|
||||
|
|
@ -183,8 +185,8 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
# Load cycles with membership_fee_type relationship
|
||||
member =
|
||||
member
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> 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)
|
||||
|
||||
|
|
@ -222,8 +224,8 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
# Load cycles and fee type first (will be empty)
|
||||
member =
|
||||
member
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> 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)
|
||||
|
||||
|
|
@ -273,12 +275,14 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
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])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> 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)
|
||||
|
|
@ -300,12 +304,14 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
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])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> 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)
|
||||
|
|
@ -327,12 +333,14 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
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])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> 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)
|
||||
|
|
@ -354,12 +362,14 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
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])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> 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)
|
||||
|
|
@ -373,12 +383,14 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
member1 = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
member2 = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
members =
|
||||
[member1, member2]
|
||||
|> Enum.map(fn m ->
|
||||
m
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|
||||
|> Ash.load!(:membership_fee_type, actor: system_actor)
|
||||
end)
|
||||
|
||||
# filter_unpaid_members should still work for backwards compatibility
|
||||
|
|
|
|||
|
|
@ -225,7 +225,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
|||
|> element("[data-testid='custom_field_#{field.id}']")
|
||||
|> render_click()
|
||||
|
||||
assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc")
|
||||
# Patch URL may include fields param (current field selection); assert sort outcome instead
|
||||
assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='ascending']")
|
||||
end
|
||||
|
||||
test "NULL values and empty strings are always sorted last (ASC)", %{conn: conn} do
|
||||
|
|
|
|||
|
|
@ -46,78 +46,76 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
|> Ash.create!(actor: actor)
|
||||
end
|
||||
|
||||
test "shows translated title in German", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = Plug.Test.init_test_session(conn, locale: "de")
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
# Expected German title
|
||||
assert html =~ "Mitglieder"
|
||||
end
|
||||
describe "translations" do
|
||||
@describetag :ui
|
||||
|
||||
test "shows translated title in English", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
Gettext.put_locale(MvWeb.Gettext, "en")
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
# Expected English title
|
||||
assert html =~ "Members"
|
||||
end
|
||||
test "shows translated title and button text by locale", %{conn: conn} do
|
||||
locales = [
|
||||
{"de", "Mitglieder", "Speichern",
|
||||
fn c -> Plug.Test.init_test_session(c, locale: "de") end},
|
||||
{"en", "Members", "Save",
|
||||
fn c ->
|
||||
Gettext.put_locale(MvWeb.Gettext, "en")
|
||||
c
|
||||
end}
|
||||
]
|
||||
|
||||
test "shows translated button text in German", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = Plug.Test.init_test_session(conn, locale: "de")
|
||||
{:ok, _view, html} = live(conn, "/members/new")
|
||||
assert html =~ "Speichern"
|
||||
end
|
||||
for {_locale, expected_title, expected_button, set_locale} <- locales do
|
||||
base = conn_with_oidc_user(conn) |> set_locale.()
|
||||
|
||||
test "shows translated button text in English", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
Gettext.put_locale(MvWeb.Gettext, "en")
|
||||
{:ok, _view, html} = live(conn, "/members/new")
|
||||
assert html =~ "Save"
|
||||
end
|
||||
{:ok, _view, index_html} = live(base, "/members")
|
||||
assert index_html =~ expected_title
|
||||
|
||||
test "shows translated flash message after creating a member in German", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = Plug.Test.init_test_session(conn, locale: "de")
|
||||
{:ok, form_view, _html} = live(conn, "/members/new")
|
||||
base_form = conn_with_oidc_user(conn) |> set_locale.()
|
||||
{:ok, _view, form_html} = live(base_form, "/members/new")
|
||||
assert form_html =~ expected_button
|
||||
end
|
||||
end
|
||||
|
||||
form_data = %{
|
||||
"member[first_name]" => "Max",
|
||||
"member[last_name]" => "Mustermann",
|
||||
"member[email]" => "max@example.com"
|
||||
}
|
||||
test "shows translated flash message after creating a member in German", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = Plug.Test.init_test_session(conn, locale: "de")
|
||||
{:ok, form_view, _html} = live(conn, "/members/new")
|
||||
|
||||
# Submit form and follow the redirect to get the flash message
|
||||
{:ok, index_view, _html} =
|
||||
form_view
|
||||
|> form("#member-form", form_data)
|
||||
|> render_submit()
|
||||
|> follow_redirect(conn, "/members")
|
||||
form_data = %{
|
||||
"member[first_name]" => "Max",
|
||||
"member[last_name]" => "Mustermann",
|
||||
"member[email]" => "max@example.com"
|
||||
}
|
||||
|
||||
assert has_element?(index_view, "#flash-group", "Mitglied wurde erfolgreich erstellt")
|
||||
end
|
||||
# Submit form and follow the redirect to get the flash message
|
||||
{:ok, index_view, _html} =
|
||||
form_view
|
||||
|> form("#member-form", form_data)
|
||||
|> render_submit()
|
||||
|> follow_redirect(conn, "/members")
|
||||
|
||||
test "shows translated flash message after creating a member in English", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, form_view, _html} = live(conn, "/members/new")
|
||||
assert has_element?(index_view, "#flash-group", "Mitglied wurde erfolgreich erstellt")
|
||||
end
|
||||
|
||||
form_data = %{
|
||||
"member[first_name]" => "Max",
|
||||
"member[last_name]" => "Mustermann",
|
||||
"member[email]" => "max@example.com"
|
||||
}
|
||||
test "shows translated flash message after creating a member in English", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, form_view, _html} = live(conn, "/members/new")
|
||||
|
||||
# Submit form and follow the redirect to get the flash message
|
||||
{:ok, index_view, _html} =
|
||||
form_view
|
||||
|> form("#member-form", form_data)
|
||||
|> render_submit()
|
||||
|> follow_redirect(conn, "/members")
|
||||
form_data = %{
|
||||
"member[first_name]" => "Max",
|
||||
"member[last_name]" => "Mustermann",
|
||||
"member[email]" => "max@example.com"
|
||||
}
|
||||
|
||||
assert has_element?(index_view, "#flash-group", "Member created successfully")
|
||||
# Submit form and follow the redirect to get the flash message
|
||||
{:ok, index_view, _html} =
|
||||
form_view
|
||||
|> form("#member-form", form_data)
|
||||
|> render_submit()
|
||||
|> follow_redirect(conn, "/members")
|
||||
|
||||
assert has_element?(index_view, "#flash-group", "Member created successfully")
|
||||
end
|
||||
end
|
||||
|
||||
describe "sorting integration" do
|
||||
@describetag :ui
|
||||
test "clicking a column header toggles sort order and updates the URL", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
|
@ -200,6 +198,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
end
|
||||
|
||||
describe "URL param handling" do
|
||||
@describetag :ui
|
||||
test "handle_params reads sort query and applies it", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
|
||||
|
|
@ -226,6 +225,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
end
|
||||
|
||||
describe "search and sort integration" do
|
||||
@describetag :ui
|
||||
test "search maintains sort state", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
|
||||
|
|
@ -253,6 +253,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
end
|
||||
end
|
||||
|
||||
@tag :ui
|
||||
test "handle_info(:search_changed) updates assigns with search results", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
|
@ -521,6 +522,50 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "export to CSV" do
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, m1} =
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Export", last_name: "One", email: "export1@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
%{member1: m1}
|
||||
end
|
||||
|
||||
test "export button is rendered when no selection and shows (all)", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# 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
|
||||
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 =~ "(1)"
|
||||
end
|
||||
|
||||
test "form has correct action and payload hidden input", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
assert html =~ "/members/export.csv"
|
||||
assert html =~ ~s(name="payload")
|
||||
assert html =~ ~s(type="hidden")
|
||||
assert html =~ ~s(name="_csrf_token")
|
||||
end
|
||||
end
|
||||
|
||||
describe "cycle status filter" do
|
||||
# Helper to create a member (only used in this describe block)
|
||||
defp create_member(attrs, actor) do
|
||||
|
|
@ -780,6 +825,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
|> Ash.create!(actor: system_actor)
|
||||
end
|
||||
|
||||
@tag :ui
|
||||
test "mount initializes boolean_custom_field_filters as empty map", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
|
@ -788,6 +834,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
assert state.socket.assigns.boolean_custom_field_filters == %{}
|
||||
end
|
||||
|
||||
@tag :ui
|
||||
test "mount initializes boolean_custom_fields as empty list when no boolean fields exist", %{
|
||||
conn: conn
|
||||
} do
|
||||
|
|
@ -1762,6 +1809,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
refute html_false =~ "NoValue"
|
||||
end
|
||||
|
||||
@tag :ui
|
||||
test "boolean custom field appears in filter dropdown after being added", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
||||
|
|
|
|||
|
|
@ -28,21 +28,6 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
|> Ash.create!(actor: system_actor)
|
||||
end
|
||||
|
||||
# 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
|
||||
|
||||
# Helper to create a cycle
|
||||
defp create_cycle(member, fee_type, attrs) do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
|
@ -73,7 +58,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
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})
|
||||
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})
|
||||
|
|
@ -95,7 +80,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
|
||||
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})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
|
|
@ -124,7 +109,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest 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})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: yearly_type.id})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
|
|
@ -132,20 +117,30 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
assert html =~ "Yearly Type"
|
||||
end
|
||||
|
||||
test "shows no type message when no type assigned", %{conn: conn} do
|
||||
member = create_member(%{})
|
||||
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}")
|
||||
{: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 = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
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})
|
||||
|
||||
|
|
@ -176,7 +171,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
|
||||
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})
|
||||
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})
|
||||
|
||||
|
|
@ -207,7 +202,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
|
||||
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})
|
||||
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})
|
||||
|
||||
|
|
@ -240,7 +235,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
describe "cycle regeneration" do
|
||||
test "manual regeneration button exists and can be clicked", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
|
|
@ -266,7 +261,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
describe "edge cases" do
|
||||
test "handles members without membership fee type gracefully", %{conn: conn} do
|
||||
# No fee type
|
||||
member = create_member(%{})
|
||||
member = Mv.Fixtures.member_fixture(%{})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
|
|
@ -274,4 +269,120 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
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
|
||||
|
|
|
|||
|
|
@ -742,6 +742,18 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
|
|||
assert conn.status == 200
|
||||
end
|
||||
|
||||
@tag role: :normal_user
|
||||
test "GET /groups/new returns 200", %{conn: conn} do
|
||||
conn = get(conn, "/groups/new")
|
||||
assert conn.status == 200
|
||||
end
|
||||
|
||||
@tag role: :normal_user
|
||||
test "GET /groups/:slug/edit returns 200", %{conn: conn, group_slug: slug} do
|
||||
conn = get(conn, "/groups/#{slug}/edit")
|
||||
assert conn.status == 200
|
||||
end
|
||||
|
||||
@tag role: :normal_user
|
||||
test "GET /members/:id/show/edit returns 200", %{conn: conn, member_id: id} do
|
||||
conn = get(conn, "/members/#{id}/show/edit")
|
||||
|
|
@ -830,22 +842,6 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
|
|||
assert redirected_to(conn) == "/users/#{user.id}"
|
||||
end
|
||||
|
||||
@tag role: :normal_user
|
||||
test "GET /groups/new redirects to user profile", %{conn: conn, current_user: user} do
|
||||
conn = get(conn, "/groups/new")
|
||||
assert redirected_to(conn) == "/users/#{user.id}"
|
||||
end
|
||||
|
||||
@tag role: :normal_user
|
||||
test "GET /groups/:slug/edit redirects to user profile", %{
|
||||
conn: conn,
|
||||
current_user: user,
|
||||
group_slug: slug
|
||||
} do
|
||||
conn = get(conn, "/groups/#{slug}/edit")
|
||||
assert redirected_to(conn) == "/users/#{user.id}"
|
||||
end
|
||||
|
||||
@tag role: :normal_user
|
||||
test "GET /admin/roles redirects to user profile", %{conn: conn, current_user: user} do
|
||||
conn = get(conn, "/admin/roles")
|
||||
|
|
|
|||
|
|
@ -213,6 +213,35 @@ defmodule MvWeb.UserLive.FormTest do
|
|||
assert not is_nil(updated_user.hashed_password)
|
||||
assert updated_user.hashed_password != ""
|
||||
end
|
||||
|
||||
test "admin can change user role and change persists", %{conn: conn} do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
role_a = Mv.Fixtures.role_fixture("normal_user")
|
||||
role_b = Mv.Fixtures.role_fixture("read_only")
|
||||
|
||||
user = create_test_user(%{email: "rolechange@example.com"})
|
||||
{:ok, user} = Mv.Accounts.update_user(user, %{role_id: role_a.id}, actor: system_actor)
|
||||
assert user.role_id == role_a.id
|
||||
|
||||
{:ok, view, _html} = setup_live_view(conn, "/users/#{user.id}/edit")
|
||||
|
||||
view
|
||||
|> form("#user-form",
|
||||
user: %{
|
||||
email: "rolechange@example.com",
|
||||
role_id: role_b.id
|
||||
}
|
||||
)
|
||||
|> render_submit()
|
||||
|
||||
assert_redirected(view, "/users")
|
||||
|
||||
updated_user = Ash.reload!(user, domain: Mv.Accounts, actor: system_actor)
|
||||
|
||||
assert updated_user.role_id == role_b.id,
|
||||
"Expected role_id to persist as #{role_b.id}, got #{inspect(updated_user.role_id)}"
|
||||
end
|
||||
end
|
||||
|
||||
describe "edit user form - validation" do
|
||||
|
|
|
|||
|
|
@ -55,7 +55,6 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
|
||||
# Should show ascending indicator (up arrow)
|
||||
assert html =~ "hero-chevron-up"
|
||||
assert html =~ ~s(aria-sort="ascending")
|
||||
|
||||
# Test actual sort order: alpha should appear before mike, mike before zulu
|
||||
alpha_pos = html |> :binary.match("alpha@example.com") |> elem(0)
|
||||
|
|
@ -76,7 +75,6 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
|
||||
# Should now show descending indicator (down arrow)
|
||||
assert html =~ "hero-chevron-down"
|
||||
assert html =~ ~s(aria-sort="descending")
|
||||
|
||||
# Test actual sort order reversed: zulu should now appear before mike, mike before alpha
|
||||
alpha_pos = html |> :binary.match("alpha@example.com") |> elem(0)
|
||||
|
|
@ -107,7 +105,6 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
# Click again to toggle back to ascending
|
||||
html = view |> element("button[phx-value-field='email']") |> render_click()
|
||||
assert html =~ "hero-chevron-up"
|
||||
assert html =~ ~s(aria-sort="ascending")
|
||||
|
||||
# Should be back to original ascending order
|
||||
alpha_pos = html |> :binary.match("alpha@example.com") |> elem(0)
|
||||
|
|
@ -379,6 +376,45 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "Password column display" do
|
||||
test "user without password shows em dash in Password column", %{conn: conn} do
|
||||
# User created with hashed_password: nil (no password) - must not get default password
|
||||
user_no_pw =
|
||||
create_test_user(%{
|
||||
email: "no-password@example.com",
|
||||
hashed_password: nil
|
||||
})
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, html} = live(conn, "/users")
|
||||
|
||||
assert html =~ "no-password@example.com"
|
||||
|
||||
# Password column must show "—" (em dash) for user without password, not "Enabled"
|
||||
row = view |> element("tr#row-#{user_no_pw.id}") |> render()
|
||||
assert row =~ "—", "Password column should show em dash for user without password"
|
||||
|
||||
refute row =~ "Enabled",
|
||||
"Password column must not show Enabled when user has no password"
|
||||
end
|
||||
|
||||
test "user with password shows Enabled in Password column", %{conn: conn} do
|
||||
user_with_pw =
|
||||
create_test_user(%{
|
||||
email: "with-password@example.com",
|
||||
password: "test123"
|
||||
})
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, html} = live(conn, "/users")
|
||||
|
||||
assert html =~ "with-password@example.com"
|
||||
|
||||
row = view |> element("tr#row-#{user_with_pw.id}") |> render()
|
||||
assert row =~ "Enabled", "Password column should show Enabled when user has password"
|
||||
end
|
||||
end
|
||||
|
||||
describe "member linking display" do
|
||||
@tag :slow
|
||||
test "displays linked member name in user list", %{conn: conn} do
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue