From 2b0e7a983b9ce83624732d590f1b5a969f5b6617 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Nov 2025 15:56:05 +0100 Subject: [PATCH] test: add LiveView tests for member linking UI (#168) --- .../user_live/form_member_linking_ui_test.exs | 433 ++++++++++++++++++ test/mv_web/user_live/form_test.exs | 97 ++++ test/mv_web/user_live/index_test.exs | 31 ++ 3 files changed, 561 insertions(+) create mode 100644 test/mv_web/user_live/form_member_linking_ui_test.exs diff --git a/test/mv_web/user_live/form_member_linking_ui_test.exs b/test/mv_web/user_live/form_member_linking_ui_test.exs new file mode 100644 index 0000000..280dca9 --- /dev/null +++ b/test/mv_web/user_live/form_member_linking_ui_test.exs @@ -0,0 +1,433 @@ +defmodule MvWeb.UserLive.FormMemberLinkingUiTest do + @moduledoc """ + UI tests for member linking in UserLive.Form. + Tests dropdown behavior, fuzzy search, selection, and unlink workflow. + Related to Issue #168. + """ + + use MvWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + alias Mv.Accounts + alias Mv.Membership + + # Helper to setup authenticated connection for admin + defp setup_admin_conn(conn) do + conn_with_oidc_user(conn, %{email: "admin@example.com"}) + end + + describe "dropdown visibility" do + test "dropdown hidden on mount", %{conn: conn} do + conn = setup_admin_conn(conn) + html = conn |> live(~p"/users/new") |> render() + + # Dropdown should not be visible initially + refute html =~ ~r/role="listbox"/ + end + + test "dropdown shows after focus event", %{conn: conn} do + conn = setup_admin_conn(conn) + # Create unlinked members + create_unlinked_members(3) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Focus the member search input + view + |> element("#member-search-input") + |> render_focus() + + html = render(view) + + # Dropdown should now be visible + assert html =~ ~r/role="listbox"/ + end + + test "dropdown shows top 10 unlinked members on focus", %{conn: conn} do + # Create 15 unlinked members + members = create_unlinked_members(15) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Focus the member search input + view + |> element("#member-search-input") + |> render_focus() + + html = render(view) + + # Should show only 10 members + shown_members = Enum.take(members, 10) + hidden_members = Enum.drop(members, 10) + + for member <- shown_members do + assert html =~ member.first_name + end + + for member <- hidden_members do + refute html =~ member.first_name + end + end + end + + describe "fuzzy search" do + test "finds member with exact name", %{conn: conn} do + {:ok, member} = + Membership.create_member(%{ + first_name: "Jonathan", + last_name: "Smith", + email: "jonathan.smith@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Type exact name + view + |> element("#member-search-input") + |> render_change(%{"member_search_query" => "Jonathan"}) + + html = render(view) + + assert html =~ "Jonathan" + assert html =~ "Smith" + end + + test "finds member with typo (Jon finds Jonathan)", %{conn: conn} do + {:ok, member} = + Membership.create_member(%{ + first_name: "Jonathan", + last_name: "Smith", + email: "jonathan.smith@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Type with typo + view + |> element("#member-search-input") + |> render_change(%{"member_search_query" => "Jon"}) + + html = render(view) + + # Fuzzy search should find Jonathan + assert html =~ "Jonathan" + assert html =~ "Smith" + end + + test "finds member with partial substring", %{conn: conn} do + {:ok, member} = + Membership.create_member(%{ + first_name: "Alexander", + last_name: "Williams", + email: "alex@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Type partial + view + |> element("#member-search-input") + |> render_change(%{"member_search_query" => "lex"}) + + html = render(view) + + assert html =~ "Alexander" + end + + test "returns empty for no matches", %{conn: conn} do + {:ok, member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "john@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Type something that doesn't match + view + |> element("#member-search-input") + |> render_change(%{"member_search_query" => "zzzzzzz"}) + + html = render(view) + + refute html =~ "John" + end + end + + describe "member selection" do + test "input field shows selected member name", %{conn: conn} do + {:ok, member} = + Membership.create_member(%{ + first_name: "Alice", + last_name: "Johnson", + email: "alice@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Focus and search + view + |> element("#member-search-input") + |> render_focus() + + # Select member + view + |> element("[data-member-id='#{member.id}']") + |> render_click() + + html = render(view) + + # Input field should show member name + assert html =~ "Alice Johnson" + end + + test "confirmation box appears", %{conn: conn} do + {:ok, member} = + Membership.create_member(%{ + first_name: "Bob", + last_name: "Williams", + email: "bob@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Focus input + view + |> element("#member-search-input") + |> render_focus() + + # Select member + view + |> element("[data-member-id='#{member.id}']") + |> render_click() + + html = render(view) + + # Confirmation box should appear + assert html =~ "Selected" + assert html =~ "Bob Williams" + assert html =~ "Save to confirm linking" + end + + test "hidden input stores member ID", %{conn: conn} do + {:ok, member} = + Membership.create_member(%{ + first_name: "Charlie", + last_name: "Brown", + email: "charlie@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Focus input + view + |> element("#member-search-input") + |> render_focus() + + # Select member + view + |> element("[data-member-id='#{member.id}']") + |> render_click() + + # Check socket assigns (member ID should be stored) + assert view |> element("#user-form") |> has_element?() + end + end + + describe "email handling" do + test "links user and member with identical email successfully", %{conn: conn} do + {:ok, member} = + Membership.create_member(%{ + first_name: "David", + last_name: "Miller", + email: "david@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Fill user form with same email + view + |> form("#user-form", user: %{email: "david@example.com"}) + |> render_change() + + # Focus input + view + |> element("#member-search-input") + |> render_focus() + + # Select member + view + |> element("[data-member-id='#{member.id}']") + |> render_click() + + # Submit form + view + |> form("#user-form", user: %{email: "david@example.com"}) + |> render_submit() + + # Should succeed without errors + assert_redirected(view, ~p"/users") + end + + test "shows info when member has same email", %{conn: conn} do + {:ok, member} = + Membership.create_member(%{ + first_name: "Emma", + last_name: "Davis", + email: "emma@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Fill user form with same email + view + |> form("#user-form", user: %{email: "emma@example.com"}) + |> render_change() + + html = render(view) + + # Should show info message about email conflict + assert html =~ "A member with this email already exists" + end + end + + describe "unlink workflow" do + test "unlink hides dropdown", %{conn: conn} do + # Create user with linked member + {:ok, member} = + Membership.create_member(%{ + first_name: "Frank", + last_name: "Wilson", + email: "frank@example.com" + }) + + {:ok, user} = + Accounts.create_user(%{ + email: "frank@example.com", + member: %{id: member.id} + }) + + {:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit") + + # Click unlink button + view + |> element("button[phx-click='unlink_member']") + |> render_click() + + html = render(view) + + # Dropdown should not be visible + refute html =~ ~r/role="listbox"/ + end + + test "unlink shows warning", %{conn: conn} do + # Create user with linked member + {:ok, member} = + Membership.create_member(%{ + first_name: "Grace", + last_name: "Taylor", + email: "grace@example.com" + }) + + {:ok, user} = + Accounts.create_user(%{ + email: "grace@example.com", + member: %{id: member.id} + }) + + {:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit") + + # Click unlink button + view + |> element("button[phx-click='unlink_member']") + |> render_click() + + html = render(view) + + # Should show warning + assert html =~ "Unlinking scheduled" + assert html =~ "Cannot select new member until saved" + end + + test "unlink disables input", %{conn: conn} do + # Create user with linked member + {:ok, member} = + Membership.create_member(%{ + first_name: "Henry", + last_name: "Anderson", + email: "henry@example.com" + }) + + {:ok, user} = + Accounts.create_user(%{ + email: "henry@example.com", + member: %{id: member.id} + }) + + {:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit") + + # Click unlink button + view + |> element("button[phx-click='unlink_member']") + |> render_click() + + html = render(view) + + # Input should be disabled + assert html =~ ~r/disabled/ + end + + test "save re-enables member selection", %{conn: conn} do + # Create user with linked member + {:ok, member} = + Membership.create_member(%{ + first_name: "Isabel", + last_name: "Martinez", + email: "isabel@example.com" + }) + + {:ok, user} = + Accounts.create_user(%{ + email: "isabel@example.com", + member: %{id: member.id} + }) + + {:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit") + + # Click unlink button + view + |> element("button[phx-click='unlink_member']") + |> render_click() + + # Submit form + view + |> form("#user-form") + |> render_submit() + + # Navigate back to edit + {:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit") + + html = render(view) + + # Should now show member selection input (not disabled) + assert html =~ "member-search-input" + refute html =~ "Unlinking scheduled" + end + end + + # Helper functions + defp create_unlinked_members(count) do + for i <- 1..count do + {:ok, member} = + Membership.create_member(%{ + first_name: "FirstName#{i}", + last_name: "LastName#{i}", + email: "member#{i}@example.com" + }) + + member + end + end +end diff --git a/test/mv_web/user_live/form_test.exs b/test/mv_web/user_live/form_test.exs index 111ff42..b8f7313 100644 --- a/test/mv_web/user_live/form_test.exs +++ b/test/mv_web/user_live/form_test.exs @@ -281,4 +281,101 @@ defmodule MvWeb.UserLive.FormTest do assert edit_html =~ "Change Password" end end + + describe "member linking - display" do + test "shows linked member with unlink button when user has member", %{conn: conn} do + # Create member + {:ok, member} = + Mv.Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "john@example.com" + }) + + # Create user linked to member + user = create_test_user(%{email: "user@example.com"}) + {:ok, _updated_user} = Mv.Accounts.update_user(user, %{member: %{id: member.id}}) + + # Load form + {:ok, view, html} = setup_live_view(conn, "/users/#{user.id}/edit") + + # Should show linked member section + assert html =~ "Linked Member" + assert html =~ "John Doe" + assert html =~ "user@example.com" + assert has_element?(view, "button[phx-click='unlink_member']") + assert html =~ "Unlink Member" + end + + test "shows member search field when user has no member", %{conn: conn} do + user = create_test_user(%{email: "user@example.com"}) + {:ok, view, html} = setup_live_view(conn, "/users/#{user.id}/edit") + + # Should show member search section + assert html =~ "Linked Member" + assert has_element?(view, "input[phx-change='search_members']") + # Should not show unlink button + refute has_element?(view, "button[phx-click='unlink_member']") + end + end + + describe "member linking - workflow" do + test "selecting member and saving links member to user", %{conn: conn} do + # Create unlinked member + {:ok, member} = + Mv.Membership.create_member(%{ + first_name: "Jane", + last_name: "Smith", + email: "jane@example.com" + }) + + # Create user without member + user = create_test_user(%{email: "user@example.com"}) + {:ok, view, _html} = setup_live_view(conn, "/users/#{user.id}/edit") + + # Select member + view |> element("div[data-member-id='#{member.id}']") |> render_click() + + # Submit form + view + |> form("#user-form", user: %{email: "user@example.com"}) + |> render_submit() + + assert_redirected(view, "/users") + + # Verify member is linked + updated_user = Ash.get!(Mv.Accounts.User, user.id, domain: Mv.Accounts, load: [:member]) + assert updated_user.member.id == member.id + end + + test "unlinking member and saving removes member from user", %{conn: conn} do + # Create member + {:ok, member} = + Mv.Membership.create_member(%{ + first_name: "Bob", + last_name: "Wilson", + email: "bob@example.com" + }) + + # Create user linked to member + user = create_test_user(%{email: "user@example.com"}) + {:ok, _} = Mv.Accounts.update_user(user, %{member: %{id: member.id}}) + + {:ok, view, _html} = setup_live_view(conn, "/users/#{user.id}/edit") + + # Click unlink button + view |> element("button[phx-click='unlink_member']") |> render_click() + + # Submit form + view + |> form("#user-form", user: %{email: "user@example.com"}) + |> render_submit() + + assert_redirected(view, "/users") + + # Verify member is unlinked + updated_user = Ash.get!(Mv.Accounts.User, user.id, domain: Mv.Accounts, load: [:member]) + assert is_nil(updated_user.member) + end + end end diff --git a/test/mv_web/user_live/index_test.exs b/test/mv_web/user_live/index_test.exs index 6393e3b..c0b0275 100644 --- a/test/mv_web/user_live/index_test.exs +++ b/test/mv_web/user_live/index_test.exs @@ -410,4 +410,35 @@ defmodule MvWeb.UserLive.IndexTest do assert html =~ long_email end end + + describe "member linking display" do + test "displays linked member name in user list", %{conn: conn} do + # Create member + {:ok, member} = + Mv.Membership.create_member(%{ + first_name: "Alice", + last_name: "Johnson", + email: "alice@example.com" + }) + + # Create user linked to member + user = create_test_user(%{email: "user@example.com"}) + {:ok, _updated_user} = Mv.Accounts.update_user(user, %{member: %{id: member.id}}) + + # Create another user without member + _unlinked_user = create_test_user(%{email: "unlinked@example.com"}) + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/users") + + # Should show linked member name + assert html =~ "Alice Johnson" + # Should show user email + assert html =~ "user@example.com" + # Should show unlinked user + assert html =~ "unlinked@example.com" + # Should show "No member linked" or similar for unlinked user + assert html =~ "No member linked" + end + end end