diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 74df997..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,19 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -### Added -- User-Member linking with fuzzy search autocomplete (#168) -- PostgreSQL trigram-based member search with typo tolerance -- WCAG 2.1 AA compliant autocomplete dropdown with ARIA support -- Bilingual UI (German/English) for member linking workflow - -### Fixed -- Email validation false positive when linking user and member with identical emails (#168 Problem #4) -- Relationship data extraction from Ash manage_relationship during validation - diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index 749740d..e7b614f 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -69,7 +69,7 @@ defmodule Mv.Accounts.User do # Default actions for framework/tooling integration: # - :read -> Standard read used across the app and by admin tooling. # - :destroy-> Standard delete used by admin tooling and maintenance tasks. - # + # # NOTE: :create is INTENTIONALLY excluded from defaults! # Using a default :create would bypass email-synchronization logic. # Always use one of these explicit create actions instead: diff --git a/lib/membership/member.ex b/lib/membership/member.ex index d8fb4d7..8464388 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -38,10 +38,6 @@ defmodule Mv.Membership.Member do require Ash.Query import Ash.Expr - # Module constants - @member_search_limit 10 - @default_similarity_threshold 0.2 - postgres do table "members" repo Mv.Repo @@ -156,10 +152,9 @@ defmodule Mv.Membership.Member do prepare fn query, _ctx -> q = Ash.Query.get_argument(query, :query) || "" - # Use default similarity threshold if not provided - # Lower value leads to more results but also more unspecific results - threshold = - Ash.Query.get_argument(query, :similarity_threshold) || @default_similarity_threshold + # 0.2 as similarity threshold (recommended) + # Lower value can lead to more results but also to more unspecific results + threshold = Ash.Query.get_argument(query, :similarity_threshold) || 0.2 if is_binary(q) and String.trim(q) != "" do q2 = String.trim(q) @@ -231,58 +226,28 @@ defmodule Mv.Membership.Member do fragment("? % first_name", ^trimmed) or fragment("? % last_name", ^trimmed) or fragment("word_similarity(?, first_name) > 0.2", ^trimmed) or - fragment( - "word_similarity(?, last_name) > ?", - ^trimmed, - ^@default_similarity_threshold - ) or - fragment( - "similarity(first_name, ?) > ?", - ^trimmed, - ^@default_similarity_threshold - ) or - fragment("similarity(last_name, ?) > ?", ^trimmed, ^@default_similarity_threshold) or + fragment("word_similarity(?, last_name) > 0.2", ^trimmed) or + fragment("similarity(first_name, ?) > 0.2", ^trimmed) or + fragment("similarity(last_name, ?) > 0.2", ^trimmed) or contains(email, ^trimmed) ) ) - |> Ash.Query.limit(@member_search_limit) + |> Ash.Query.limit(10) else # No search query: return all unlinked members # Caller should use filter_by_email_match helper for email match logic base_query - |> Ash.Query.limit(@member_search_limit) + |> Ash.Query.limit(10) end end end end - @doc """ - Filters members list to return only email match if exists. - - If a member with matching email exists in the list, returns only that member. - Otherwise returns all members unchanged (no filtering). - - This is typically used after calling `:available_for_linking` action with - a user_email argument to apply email-match priority logic. - - ## Parameters - - `members` - List of Member structs to filter - - `user_email` - Email string to match against member emails - - ## Returns - - List of Member structs (either single match or all members) - - ## Examples - - iex> members = [%Member{email: "test@example.com"}, %Member{email: "other@example.com"}] - iex> filter_by_email_match(members, "test@example.com") - [%Member{email: "test@example.com"}] - - iex> filter_by_email_match(members, "nomatch@example.com") - [%Member{email: "test@example.com"}, %Member{email: "other@example.com"}] - - """ - @spec filter_by_email_match([t()], String.t()) :: [t()] + # Public helper function to apply email match logic after query execution + # This should be called after using :available_for_linking with user_email argument + # + # If a member with matching email exists, returns only that member + # Otherwise returns all members (no filtering) def filter_by_email_match(members, user_email) when is_list(members) and is_binary(user_email) do # Check if any member matches the email @@ -297,7 +262,6 @@ defmodule Mv.Membership.Member do end end - @spec filter_by_email_match(any(), any()) :: any() def filter_by_email_match(members, _user_email), do: members validations do @@ -472,32 +436,7 @@ defmodule Mv.Membership.Member do identity :unique_email, [:email] end - @doc """ - Performs fuzzy search on members using PostgreSQL trigram similarity. - - Wraps the `:search` action with convenient opts-based argument passing. - Searches across first_name, last_name, email, and other text fields using - full-text search combined with trigram similarity. - - ## Parameters - - `query` - Ash.Query.t() to apply search to - - `opts` - Keyword list or map with search options: - - `:query` or `"query"` - Search string - - `:fields` or `"fields"` - Optional field restrictions - - ## Returns - - Modified Ash.Query.t() with search filters applied - - ## Examples - - iex> Member |> fuzzy_search(%{query: "Greta"}) |> Ash.read!() - [%Member{first_name: "Greta", ...}] - - iex> Member |> fuzzy_search(%{query: "gre"}) |> Ash.read!() # typo-tolerant - [%Member{first_name: "Greta", ...}] - - """ - @spec fuzzy_search(Ash.Query.t(), keyword() | map()) :: Ash.Query.t() + # Fuzzy Search function that can be called by live view and calls search action def fuzzy_search(query, opts) do q = (opts[:query] || opts["query"] || "") |> to_string() diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index 9cf3f59..82df862 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -267,7 +267,6 @@ defmodule MvWeb.UserLive.Form do |> assign_form()} end - @spec return_to(String.t() | nil) :: String.t() defp return_to("show"), do: "show" defp return_to(_), do: "index" @@ -384,10 +383,8 @@ defmodule MvWeb.UserLive.Form do {:noreply, socket} end - @spec notify_parent(any()) :: any() defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) - @spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do form = if user do @@ -407,11 +404,10 @@ defmodule MvWeb.UserLive.Form do assign(socket, form: to_form(form)) end - @spec return_path(String.t(), Mv.Accounts.User.t() | nil) :: String.t() defp return_path("index", _user), do: ~p"/users" defp return_path("show", user), do: ~p"/users/#{user.id}" - @spec load_initial_members(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() + # Load initial members when the form is loaded or member is unlinked defp load_initial_members(socket) do user = socket.assigns.user user_email = if user, do: user.email, else: nil @@ -425,8 +421,7 @@ defmodule MvWeb.UserLive.Form do |> assign(show_member_dropdown: false) end - @spec load_available_members(Phoenix.LiveView.Socket.t(), String.t()) :: - Phoenix.LiveView.Socket.t() + # Load members based on search query defp load_available_members(socket, query) do user = socket.assigns.user user_email = if user, do: user.email, else: nil @@ -435,7 +430,7 @@ defmodule MvWeb.UserLive.Form do assign(socket, available_members: members) end - @spec load_members_for_linking(String.t() | nil, String.t() | nil) :: [Mv.Membership.Member.t()] + # Query available members using the Ash action defp load_members_for_linking(user_email, search_query) do user_email_str = if user_email, do: to_string(user_email), else: nil search_query_str = if search_query && search_query != "", do: search_query, else: nil diff --git a/test/accounts/debug_changeset_test.exs b/test/accounts/debug_changeset_test.exs new file mode 100644 index 0000000..04a4df8 --- /dev/null +++ b/test/accounts/debug_changeset_test.exs @@ -0,0 +1,33 @@ +defmodule Mv.Accounts.DebugChangesetTest do + use Mv.DataCase, async: true + + alias Mv.Accounts + alias Mv.Membership + + test "debug: what's in the changeset when linking with same email" do + # Create member + {:ok, member} = + Membership.create_member(%{ + first_name: "Emma", + last_name: "Davis", + email: "emma@example.com" + }) + + IO.puts("\n=== MEMBER CREATED ===") + IO.puts("Member ID: #{member.id}") + IO.puts("Member Email: #{member.email}") + + # Try to create user with same email and link + IO.puts("\n=== ATTEMPTING TO CREATE USER WITH LINK ===") + + # Let's intercept the validation to see what's in the changeset + result = + Accounts.create_user(%{ + email: "emma@example.com", + member: %{id: member.id} + }) + + IO.puts("\n=== RESULT ===") + IO.inspect(result, label: "Result") + end +end diff --git a/test/accounts/user_member_linking_test.exs b/test/accounts/user_member_linking_test.exs index 1111436..8072eaf 100644 --- a/test/accounts/user_member_linking_test.exs +++ b/test/accounts/user_member_linking_test.exs @@ -5,7 +5,7 @@ defmodule Mv.Accounts.UserMemberLinkingTest do Tests the complete workflow of linking and unlinking members to users, including email synchronization and validation rules. """ - use Mv.DataCase, async: false + use Mv.DataCase, async: true alias Mv.Accounts alias Mv.Membership diff --git a/test/mv_web/user_live/form_debug2_test.exs b/test/mv_web/user_live/form_debug2_test.exs new file mode 100644 index 0000000..7847bb0 --- /dev/null +++ b/test/mv_web/user_live/form_debug2_test.exs @@ -0,0 +1,48 @@ +defmodule MvWeb.UserLive.FormDebug2Test do + use Mv.DataCase, async: true + + describe "direct ash query test" do + test "check if available_for_linking works in LiveView context" do + # Create an unlinked member + {:ok, member} = + Mv.Membership.create_member(%{ + first_name: "Jane", + last_name: "Smith", + email: "jane@example.com" + }) + + IO.puts("\n=== Created member: #{inspect(member.id)} ===") + + # Try the same query as in the LiveView + user_email_str = "user@example.com" + search_query_str = nil + + IO.puts("\n=== Calling Ash.read with domain: Mv.Membership ===") + + result = + Ash.read(Mv.Membership.Member, + domain: Mv.Membership, + action: :available_for_linking, + arguments: %{user_email: user_email_str, search_query: search_query_str} + ) + + IO.puts("Result: #{inspect(result)}") + + case result do + {:ok, members} -> + IO.puts("\n✓ Query succeeded, found #{length(members)} members") + + Enum.each(members, fn m -> + IO.puts(" - #{m.first_name} #{m.last_name} (#{m.email})") + end) + + # Apply filter + filtered = Mv.Membership.Member.filter_by_email_match(members, user_email_str) + IO.puts("\n✓ After filter_by_email_match: #{length(filtered)} members") + + {:error, error} -> + IO.puts("\n✗ Query failed: #{inspect(error)}") + end + end + end +end diff --git a/test/mv_web/user_live/form_debug_test.exs b/test/mv_web/user_live/form_debug_test.exs new file mode 100644 index 0000000..0731699 --- /dev/null +++ b/test/mv_web/user_live/form_debug_test.exs @@ -0,0 +1,52 @@ +defmodule MvWeb.UserLive.FormDebugTest do + use MvWeb.ConnCase, async: true + import Phoenix.LiveViewTest + + # Helper to setup authenticated connection and live view + defp setup_live_view(conn, path) do + conn = conn_with_oidc_user(conn, %{email: "admin@example.com"}) + live(conn, path) + end + + describe "debug member loading" do + test "check if members are loaded on mount", %{conn: conn} do + # Create an 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"}) + + # Mount the form + {:ok, view, html} = setup_live_view(conn, "/users/#{user.id}/edit") + + # Debug: Check what's in the HTML + IO.puts("\n=== HTML OUTPUT ===") + IO.puts(html) + IO.puts("\n=== END HTML ===") + + # Check socket assigns + IO.puts("\n=== SOCKET ASSIGNS ===") + assigns = :sys.get_state(view.pid).socket.assigns + IO.puts("available_members: #{inspect(assigns[:available_members])}") + IO.puts("show_member_dropdown: #{inspect(assigns[:show_member_dropdown])}") + IO.puts("member_search_query: #{inspect(assigns[:member_search_query])}") + IO.puts("user.member: #{inspect(assigns[:user].member)}") + IO.puts("\n=== END ASSIGNS ===") + + # Try to find the dropdown + assert has_element?(view, "input[name='member_search']") + + # Check if member is in the dropdown + if has_element?(view, "div[data-member-id='#{member.id}']") do + IO.puts("\n✓ Member found in dropdown") + else + IO.puts("\n✗ Member NOT found in dropdown") + end + end + end +end diff --git a/test/mv_web/user_live/form_member_dropdown_test.exs b/test/mv_web/user_live/form_member_dropdown_test.exs deleted file mode 100644 index 0e93d4d..0000000 --- a/test/mv_web/user_live/form_member_dropdown_test.exs +++ /dev/null @@ -1,149 +0,0 @@ -defmodule MvWeb.UserLive.FormMemberDropdownTest do - @moduledoc """ - UI tests for member linking dropdown visibility and email handling. - Tests dropdown behavior, visibility states, and email conflict scenarios. - Related to Issue #168. - """ - - use MvWeb.ConnCase, async: true - - import Phoenix.LiveViewTest - - 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) - {:ok, _view, html} = live(conn, ~p"/users/new") - - # 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 - conn = setup_admin_conn(conn) - # 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) - - # Count how many member entries are shown in the dropdown - # Each member creates a div with role="option" - member_count = html |> String.split(~r/role="option"/) |> length() |> Kernel.-(1) - - # Should show exactly 10 members (limit) - assert member_count == 10 - end - end - - describe "email handling" do - test "links user and member with identical email successfully", %{conn: conn} do - conn = setup_admin_conn(conn) - - {: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 member with same email in dropdown", %{conn: conn} do - conn = setup_admin_conn(conn) - - {: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() - - # Focus the member search to trigger loading - view - |> element("#member-search-input") - |> render_focus() - - html = render(view) - - # Should show member with matching email in dropdown - assert html =~ "Emma Davis" - assert html =~ "emma@example.com" - 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_member_selection_test.exs b/test/mv_web/user_live/form_member_linking_ui_test.exs similarity index 50% rename from test/mv_web/user_live/form_member_selection_test.exs rename to test/mv_web/user_live/form_member_linking_ui_test.exs index 74810df..280dca9 100644 --- a/test/mv_web/user_live/form_member_selection_test.exs +++ b/test/mv_web/user_live/form_member_linking_ui_test.exs @@ -1,7 +1,7 @@ -defmodule MvWeb.UserLive.FormMemberSelectionTest do +defmodule MvWeb.UserLive.FormMemberLinkingUiTest do @moduledoc """ - UI tests for member selection and unlink workflow. - Tests member selection behavior and unlink process. + UI tests for member linking in UserLive.Form. + Tests dropdown behavior, fuzzy search, selection, and unlink workflow. Related to Issue #168. """ @@ -17,10 +17,147 @@ defmodule MvWeb.UserLive.FormMemberSelectionTest 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 - conn = setup_admin_conn(conn) - {:ok, member} = Membership.create_member(%{ first_name: "Alice", @@ -47,8 +184,6 @@ defmodule MvWeb.UserLive.FormMemberSelectionTest do end test "confirmation box appears", %{conn: conn} do - conn = setup_admin_conn(conn) - {:ok, member} = Membership.create_member(%{ first_name: "Bob", @@ -77,8 +212,6 @@ defmodule MvWeb.UserLive.FormMemberSelectionTest do end test "hidden input stores member ID", %{conn: conn} do - conn = setup_admin_conn(conn) - {:ok, member} = Membership.create_member(%{ first_name: "Charlie", @@ -103,9 +236,65 @@ defmodule MvWeb.UserLive.FormMemberSelectionTest do 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 - conn = setup_admin_conn(conn) # Create user with linked member {:ok, member} = Membership.create_member(%{ @@ -134,7 +323,6 @@ defmodule MvWeb.UserLive.FormMemberSelectionTest do end test "unlink shows warning", %{conn: conn} do - conn = setup_admin_conn(conn) # Create user with linked member {:ok, member} = Membership.create_member(%{ @@ -164,7 +352,6 @@ defmodule MvWeb.UserLive.FormMemberSelectionTest do end test "unlink disables input", %{conn: conn} do - conn = setup_admin_conn(conn) # Create user with linked member {:ok, member} = Membership.create_member(%{ @@ -193,7 +380,6 @@ defmodule MvWeb.UserLive.FormMemberSelectionTest do end test "save re-enables member selection", %{conn: conn} do - conn = setup_admin_conn(conn) # Create user with linked member {:ok, member} = Membership.create_member(%{ @@ -230,4 +416,18 @@ defmodule MvWeb.UserLive.FormMemberSelectionTest do 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_member_search_test.exs b/test/mv_web/user_live/form_member_search_test.exs deleted file mode 100644 index 6b07e4f..0000000 --- a/test/mv_web/user_live/form_member_search_test.exs +++ /dev/null @@ -1,112 +0,0 @@ -defmodule MvWeb.UserLive.FormMemberSearchTest do - @moduledoc """ - UI tests for fuzzy search functionality in member linking. - Tests PostgreSQL trigram-based fuzzy search behavior. - Related to Issue #168. - """ - - use MvWeb.ConnCase, async: true - - import Phoenix.LiveViewTest - - 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 "fuzzy search" do - test "finds member with exact name", %{conn: conn} do - conn = setup_admin_conn(conn) - - {: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 - conn = setup_admin_conn(conn) - - {: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 - conn = setup_admin_conn(conn) - - {: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 "shows partial match with similar names", %{conn: conn} do - conn = setup_admin_conn(conn) - - {:ok, _member} = - Membership.create_member(%{ - first_name: "Johnny", - last_name: "Doeson", - email: "johnny@example.com" - }) - - {:ok, view, _html} = live(conn, ~p"/users/new") - - # Type partial match - view - |> element("#member-search-input") - |> render_change(%{"member_search_query" => "John"}) - - html = render(view) - - # Should find member with similar name - assert html =~ "Johnny" - end - end -end diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex deleted file mode 100644 index 5dd14a9..0000000 --- a/test/support/fixtures.ex +++ /dev/null @@ -1,96 +0,0 @@ -defmodule Mv.Fixtures do - @moduledoc """ - Shared test fixtures for consistent test data creation. - - This module provides factory functions for creating test data across - different test suites, ensuring consistency and reducing duplication. - """ - - @doc """ - Creates a member with default or custom attributes. - - ## Parameters - - `attrs` - Map or keyword list of attributes to override defaults - - ## Returns - - Member struct - - ## Examples - - iex> member_fixture() - %Mv.Membership.Member{first_name: "Test", ...} - - iex> member_fixture(%{first_name: "Alice", email: "alice@example.com"}) - %Mv.Membership.Member{first_name: "Alice", email: "alice@example.com"} - - """ - def member_fixture(attrs \\ %{}) do - attrs - |> Enum.into(%{ - first_name: "Test", - last_name: "Member", - email: "test#{System.unique_integer([:positive])}@example.com" - }) - |> Mv.Membership.create_member() - |> case do - {:ok, member} -> member - {:error, error} -> raise "Failed to create member: #{inspect(error)}" - end - end - - @doc """ - Creates a user with default or custom attributes. - - ## Parameters - - `attrs` - Map or keyword list of attributes to override defaults - - ## Returns - - User struct - - ## Examples - - iex> user_fixture() - %Mv.Accounts.User{email: "user123@example.com"} - - iex> user_fixture(%{email: "custom@example.com"}) - %Mv.Accounts.User{email: "custom@example.com"} - - """ - def user_fixture(attrs \\ %{}) do - attrs - |> Enum.into(%{ - email: "user#{System.unique_integer([:positive])}@example.com" - }) - |> Mv.Accounts.create_user() - |> case do - {:ok, user} -> user - {:error, error} -> raise "Failed to create user: #{inspect(error)}" - end - end - - @doc """ - Creates a user linked to a member. - - ## Parameters - - `user_attrs` - Map or keyword list of user attributes - - `member_attrs` - Map or keyword list of member attributes - - ## Returns - - Tuple of {user, member} - - ## Examples - - iex> {user, member} = linked_user_member_fixture() - iex> user.member_id == member.id - true - - """ - def linked_user_member_fixture(user_attrs \\ %{}, member_attrs \\ %{}) do - member = member_fixture(member_attrs) - - user_attrs = Map.put(user_attrs, :member, %{id: member.id}) - user = user_fixture(user_attrs) - - {user, member} - end -end