Compare commits
8 commits
1819a1e2d1
...
62d472cee6
| Author | SHA1 | Date | |
|---|---|---|---|
| 62d472cee6 | |||
| 814b84c120 | |||
| a67a2780b7 | |||
| eaaa2f37dc | |||
| 56cfa01711 | |||
| e30778d286 | |||
| afd3cc88dc | |||
| 88bed75ac9 |
12 changed files with 474 additions and 365 deletions
19
CHANGELOG.md
Normal file
19
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
@ -69,7 +69,7 @@ defmodule Mv.Accounts.User do
|
||||||
# Default actions for framework/tooling integration:
|
# Default actions for framework/tooling integration:
|
||||||
# - :read -> Standard read used across the app and by admin tooling.
|
# - :read -> Standard read used across the app and by admin tooling.
|
||||||
# - :destroy-> Standard delete used by admin tooling and maintenance tasks.
|
# - :destroy-> Standard delete used by admin tooling and maintenance tasks.
|
||||||
#
|
#
|
||||||
# NOTE: :create is INTENTIONALLY excluded from defaults!
|
# NOTE: :create is INTENTIONALLY excluded from defaults!
|
||||||
# Using a default :create would bypass email-synchronization logic.
|
# Using a default :create would bypass email-synchronization logic.
|
||||||
# Always use one of these explicit create actions instead:
|
# Always use one of these explicit create actions instead:
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,10 @@ defmodule Mv.Membership.Member do
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
|
|
||||||
|
# Module constants
|
||||||
|
@member_search_limit 10
|
||||||
|
@default_similarity_threshold 0.2
|
||||||
|
|
||||||
postgres do
|
postgres do
|
||||||
table "members"
|
table "members"
|
||||||
repo Mv.Repo
|
repo Mv.Repo
|
||||||
|
|
@ -152,9 +156,10 @@ defmodule Mv.Membership.Member do
|
||||||
prepare fn query, _ctx ->
|
prepare fn query, _ctx ->
|
||||||
q = Ash.Query.get_argument(query, :query) || ""
|
q = Ash.Query.get_argument(query, :query) || ""
|
||||||
|
|
||||||
# 0.2 as similarity threshold (recommended)
|
# Use default similarity threshold if not provided
|
||||||
# Lower value can lead to more results but also to more unspecific results
|
# Lower value leads to more results but also more unspecific results
|
||||||
threshold = Ash.Query.get_argument(query, :similarity_threshold) || 0.2
|
threshold =
|
||||||
|
Ash.Query.get_argument(query, :similarity_threshold) || @default_similarity_threshold
|
||||||
|
|
||||||
if is_binary(q) and String.trim(q) != "" do
|
if is_binary(q) and String.trim(q) != "" do
|
||||||
q2 = String.trim(q)
|
q2 = String.trim(q)
|
||||||
|
|
@ -226,28 +231,58 @@ defmodule Mv.Membership.Member do
|
||||||
fragment("? % first_name", ^trimmed) or
|
fragment("? % first_name", ^trimmed) or
|
||||||
fragment("? % last_name", ^trimmed) or
|
fragment("? % last_name", ^trimmed) or
|
||||||
fragment("word_similarity(?, first_name) > 0.2", ^trimmed) or
|
fragment("word_similarity(?, first_name) > 0.2", ^trimmed) or
|
||||||
fragment("word_similarity(?, last_name) > 0.2", ^trimmed) or
|
fragment(
|
||||||
fragment("similarity(first_name, ?) > 0.2", ^trimmed) or
|
"word_similarity(?, last_name) > ?",
|
||||||
fragment("similarity(last_name, ?) > 0.2", ^trimmed) or
|
^trimmed,
|
||||||
|
^@default_similarity_threshold
|
||||||
|
) or
|
||||||
|
fragment(
|
||||||
|
"similarity(first_name, ?) > ?",
|
||||||
|
^trimmed,
|
||||||
|
^@default_similarity_threshold
|
||||||
|
) or
|
||||||
|
fragment("similarity(last_name, ?) > ?", ^trimmed, ^@default_similarity_threshold) or
|
||||||
contains(email, ^trimmed)
|
contains(email, ^trimmed)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|> Ash.Query.limit(10)
|
|> Ash.Query.limit(@member_search_limit)
|
||||||
else
|
else
|
||||||
# No search query: return all unlinked members
|
# No search query: return all unlinked members
|
||||||
# Caller should use filter_by_email_match helper for email match logic
|
# Caller should use filter_by_email_match helper for email match logic
|
||||||
base_query
|
base_query
|
||||||
|> Ash.Query.limit(10)
|
|> Ash.Query.limit(@member_search_limit)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Public helper function to apply email match logic after query execution
|
@doc """
|
||||||
# This should be called after using :available_for_linking with user_email argument
|
Filters members list to return only email match if exists.
|
||||||
#
|
|
||||||
# If a member with matching email exists, returns only that member
|
If a member with matching email exists in the list, returns only that member.
|
||||||
# Otherwise returns all members (no filtering)
|
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()]
|
||||||
def filter_by_email_match(members, user_email)
|
def filter_by_email_match(members, user_email)
|
||||||
when is_list(members) and is_binary(user_email) do
|
when is_list(members) and is_binary(user_email) do
|
||||||
# Check if any member matches the email
|
# Check if any member matches the email
|
||||||
|
|
@ -262,6 +297,7 @@ defmodule Mv.Membership.Member do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec filter_by_email_match(any(), any()) :: any()
|
||||||
def filter_by_email_match(members, _user_email), do: members
|
def filter_by_email_match(members, _user_email), do: members
|
||||||
|
|
||||||
validations do
|
validations do
|
||||||
|
|
@ -436,7 +472,32 @@ defmodule Mv.Membership.Member do
|
||||||
identity :unique_email, [:email]
|
identity :unique_email, [:email]
|
||||||
end
|
end
|
||||||
|
|
||||||
# Fuzzy Search function that can be called by live view and calls search action
|
@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()
|
||||||
def fuzzy_search(query, opts) do
|
def fuzzy_search(query, opts) do
|
||||||
q = (opts[:query] || opts["query"] || "") |> to_string()
|
q = (opts[:query] || opts["query"] || "") |> to_string()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -267,6 +267,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
|> assign_form()}
|
|> assign_form()}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec return_to(String.t() | nil) :: String.t()
|
||||||
defp return_to("show"), do: "show"
|
defp return_to("show"), do: "show"
|
||||||
defp return_to(_), do: "index"
|
defp return_to(_), do: "index"
|
||||||
|
|
||||||
|
|
@ -383,8 +384,10 @@ defmodule MvWeb.UserLive.Form do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec notify_parent(any()) :: any()
|
||||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
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
|
defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do
|
||||||
form =
|
form =
|
||||||
if user do
|
if user do
|
||||||
|
|
@ -404,10 +407,11 @@ defmodule MvWeb.UserLive.Form do
|
||||||
assign(socket, form: to_form(form))
|
assign(socket, form: to_form(form))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec return_path(String.t(), Mv.Accounts.User.t() | nil) :: String.t()
|
||||||
defp return_path("index", _user), do: ~p"/users"
|
defp return_path("index", _user), do: ~p"/users"
|
||||||
defp return_path("show", user), do: ~p"/users/#{user.id}"
|
defp return_path("show", user), do: ~p"/users/#{user.id}"
|
||||||
|
|
||||||
# Load initial members when the form is loaded or member is unlinked
|
@spec load_initial_members(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
|
||||||
defp load_initial_members(socket) do
|
defp load_initial_members(socket) do
|
||||||
user = socket.assigns.user
|
user = socket.assigns.user
|
||||||
user_email = if user, do: user.email, else: nil
|
user_email = if user, do: user.email, else: nil
|
||||||
|
|
@ -421,7 +425,8 @@ defmodule MvWeb.UserLive.Form do
|
||||||
|> assign(show_member_dropdown: false)
|
|> assign(show_member_dropdown: false)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Load members based on search query
|
@spec load_available_members(Phoenix.LiveView.Socket.t(), String.t()) ::
|
||||||
|
Phoenix.LiveView.Socket.t()
|
||||||
defp load_available_members(socket, query) do
|
defp load_available_members(socket, query) do
|
||||||
user = socket.assigns.user
|
user = socket.assigns.user
|
||||||
user_email = if user, do: user.email, else: nil
|
user_email = if user, do: user.email, else: nil
|
||||||
|
|
@ -430,7 +435,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
assign(socket, available_members: members)
|
assign(socket, available_members: members)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Query available members using the Ash action
|
@spec load_members_for_linking(String.t() | nil, String.t() | nil) :: [Mv.Membership.Member.t()]
|
||||||
defp load_members_for_linking(user_email, search_query) do
|
defp load_members_for_linking(user_email, search_query) do
|
||||||
user_email_str = if user_email, do: to_string(user_email), else: nil
|
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
|
search_query_str = if search_query && search_query != "", do: search_query, else: nil
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -5,7 +5,7 @@ defmodule Mv.Accounts.UserMemberLinkingTest do
|
||||||
Tests the complete workflow of linking and unlinking members to users,
|
Tests the complete workflow of linking and unlinking members to users,
|
||||||
including email synchronization and validation rules.
|
including email synchronization and validation rules.
|
||||||
"""
|
"""
|
||||||
use Mv.DataCase, async: true
|
use Mv.DataCase, async: false
|
||||||
alias Mv.Accounts
|
alias Mv.Accounts
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
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
|
|
||||||
149
test/mv_web/user_live/form_member_dropdown_test.exs
Normal file
149
test/mv_web/user_live/form_member_dropdown_test.exs
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
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
|
||||||
112
test/mv_web/user_live/form_member_search_test.exs
Normal file
112
test/mv_web/user_live/form_member_search_test.exs
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
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
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
defmodule MvWeb.UserLive.FormMemberLinkingUiTest do
|
defmodule MvWeb.UserLive.FormMemberSelectionTest do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
UI tests for member linking in UserLive.Form.
|
UI tests for member selection and unlink workflow.
|
||||||
Tests dropdown behavior, fuzzy search, selection, and unlink workflow.
|
Tests member selection behavior and unlink process.
|
||||||
Related to Issue #168.
|
Related to Issue #168.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -17,147 +17,10 @@ defmodule MvWeb.UserLive.FormMemberLinkingUiTest do
|
||||||
conn_with_oidc_user(conn, %{email: "admin@example.com"})
|
conn_with_oidc_user(conn, %{email: "admin@example.com"})
|
||||||
end
|
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
|
describe "member selection" do
|
||||||
test "input field shows selected member name", %{conn: conn} do
|
test "input field shows selected member name", %{conn: conn} do
|
||||||
|
conn = setup_admin_conn(conn)
|
||||||
|
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(%{
|
||||||
first_name: "Alice",
|
first_name: "Alice",
|
||||||
|
|
@ -184,6 +47,8 @@ defmodule MvWeb.UserLive.FormMemberLinkingUiTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "confirmation box appears", %{conn: conn} do
|
test "confirmation box appears", %{conn: conn} do
|
||||||
|
conn = setup_admin_conn(conn)
|
||||||
|
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(%{
|
||||||
first_name: "Bob",
|
first_name: "Bob",
|
||||||
|
|
@ -212,6 +77,8 @@ defmodule MvWeb.UserLive.FormMemberLinkingUiTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "hidden input stores member ID", %{conn: conn} do
|
test "hidden input stores member ID", %{conn: conn} do
|
||||||
|
conn = setup_admin_conn(conn)
|
||||||
|
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(%{
|
||||||
first_name: "Charlie",
|
first_name: "Charlie",
|
||||||
|
|
@ -236,65 +103,9 @@ defmodule MvWeb.UserLive.FormMemberLinkingUiTest do
|
||||||
end
|
end
|
||||||
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
|
describe "unlink workflow" do
|
||||||
test "unlink hides dropdown", %{conn: conn} do
|
test "unlink hides dropdown", %{conn: conn} do
|
||||||
|
conn = setup_admin_conn(conn)
|
||||||
# Create user with linked member
|
# Create user with linked member
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(%{
|
||||||
|
|
@ -323,6 +134,7 @@ defmodule MvWeb.UserLive.FormMemberLinkingUiTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "unlink shows warning", %{conn: conn} do
|
test "unlink shows warning", %{conn: conn} do
|
||||||
|
conn = setup_admin_conn(conn)
|
||||||
# Create user with linked member
|
# Create user with linked member
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(%{
|
||||||
|
|
@ -352,6 +164,7 @@ defmodule MvWeb.UserLive.FormMemberLinkingUiTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "unlink disables input", %{conn: conn} do
|
test "unlink disables input", %{conn: conn} do
|
||||||
|
conn = setup_admin_conn(conn)
|
||||||
# Create user with linked member
|
# Create user with linked member
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(%{
|
||||||
|
|
@ -380,6 +193,7 @@ defmodule MvWeb.UserLive.FormMemberLinkingUiTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "save re-enables member selection", %{conn: conn} do
|
test "save re-enables member selection", %{conn: conn} do
|
||||||
|
conn = setup_admin_conn(conn)
|
||||||
# Create user with linked member
|
# Create user with linked member
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(%{
|
||||||
|
|
@ -416,18 +230,4 @@ defmodule MvWeb.UserLive.FormMemberLinkingUiTest do
|
||||||
refute html =~ "Unlinking scheduled"
|
refute html =~ "Unlinking scheduled"
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
96
test/support/fixtures.ex
Normal file
96
test/support/fixtures.ex
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue