Add actor parameter to all tests requiring authorization

This commit adds actor: system_actor to all Ash operations in tests that
require authorization.
This commit is contained in:
Moritz 2026-01-23 20:00:24 +01:00
parent 686f69c9e9
commit 0f48a9b15a
Signed by: moritz
GPG key ID: 1020A035E5DD0824
75 changed files with 4686 additions and 2859 deletions

View file

@ -85,10 +85,13 @@ defmodule Mv.MembershipFees.MembershipFeeType do
if changeset.action_type == :destroy do
require Ash.Query
# Use system_actor for validation queries (systemic operation)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
member_count =
Mv.Membership.Member
|> Ash.Query.filter(membership_fee_type_id == ^changeset.data.id)
|> Ash.count!()
|> Ash.count!(actor: system_actor)
if member_count > 0 do
{:error,
@ -108,10 +111,13 @@ defmodule Mv.MembershipFees.MembershipFeeType do
if changeset.action_type == :destroy do
require Ash.Query
# Use system_actor for validation queries (systemic operation)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
cycle_count =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Query.filter(membership_fee_type_id == ^changeset.data.id)
|> Ash.count!()
|> Ash.count!(actor: system_actor)
if cycle_count > 0 do
{:error,
@ -131,10 +137,13 @@ defmodule Mv.MembershipFees.MembershipFeeType do
if changeset.action_type == :destroy do
require Ash.Query
# Use system_actor for validation queries (systemic operation)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
setting_count =
Mv.Membership.Setting
|> Ash.Query.filter(default_membership_fee_type_id == ^changeset.data.id)
|> Ash.count!()
|> Ash.count!(actor: system_actor)
if setting_count > 0 do
{:error,

View file

@ -512,7 +512,10 @@ defmodule Mv.Membership.Import.MemberCSV do
member_attrs_with_cf
end
case Mv.Membership.create_member(final_attrs) do
# Use system_actor for CSV imports (systemic operation)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
case Mv.Membership.create_member(final_attrs, actor: system_actor) do
{:ok, member} ->
{:ok, member}

View file

@ -7,6 +7,11 @@ defmodule Mv.Accounts.EmailSyncEdgeCasesTest do
alias Mv.Accounts
alias Mv.Membership
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "Email sync edge cases" do
@valid_user_attrs %{
email: "user@example.com"
@ -18,15 +23,15 @@ defmodule Mv.Accounts.EmailSyncEdgeCasesTest do
email: "member@example.com"
}
test "simultaneous email updates use user email as source of truth" do
test "simultaneous email updates use user email as source of truth", %{actor: actor} do
# Create linked user and member
{:ok, member} = Membership.create_member(@valid_member_attrs)
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
{:ok, user} =
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}), actor: actor)
# Verify link and initial sync
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
assert synced_member.email == "user@example.com"
# Scenario: Both emails are updated "simultaneously"
@ -35,58 +40,60 @@ defmodule Mv.Accounts.EmailSyncEdgeCasesTest do
# Update member email first
{:ok, _updated_member} =
Membership.update_member(member, %{email: "member-new@example.com"})
Membership.update_member(member, %{email: "member-new@example.com"}, actor: actor)
# Verify it synced to user
{:ok, user_after_member_update} = Ash.get(Mv.Accounts.User, user.id)
{:ok, user_after_member_update} = Ash.get(Mv.Accounts.User, user.id, actor: actor)
assert to_string(user_after_member_update.email) == "member-new@example.com"
# Now update user email - this should override
{:ok, _updated_user} =
Accounts.update_user(user_after_member_update, %{email: "user-final@example.com"})
Accounts.update_user(user_after_member_update, %{email: "user-final@example.com"},
actor: actor
)
# Reload both
{:ok, final_user} = Ash.get(Mv.Accounts.User, user.id)
{:ok, final_member} = Ash.get(Mv.Membership.Member, member.id)
{:ok, final_user} = Ash.get(Mv.Accounts.User, user.id, actor: actor)
{:ok, final_member} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
# User email should be the final truth
assert to_string(final_user.email) == "user-final@example.com"
assert final_member.email == "user-final@example.com"
end
test "email validation works for both user and member" do
test "email validation works for both user and member", %{actor: actor} do
# Test that invalid emails are rejected for both resources
# Invalid email for user
invalid_user_result = Accounts.create_user(%{email: "not-an-email"})
invalid_user_result = Accounts.create_user(%{email: "not-an-email"}, actor: actor)
assert {:error, %Ash.Error.Invalid{}} = invalid_user_result
# Invalid email for member
invalid_member_attrs = Map.put(@valid_member_attrs, :email, "also-not-an-email")
invalid_member_result = Membership.create_member(invalid_member_attrs)
invalid_member_result = Membership.create_member(invalid_member_attrs, actor: actor)
assert {:error, %Ash.Error.Invalid{}} = invalid_member_result
# Valid emails should work
{:ok, _user} = Accounts.create_user(@valid_user_attrs)
{:ok, _member} = Membership.create_member(@valid_member_attrs)
{:ok, _user} = Accounts.create_user(@valid_user_attrs, actor: actor)
{:ok, _member} = Membership.create_member(@valid_member_attrs, actor: actor)
end
test "identity constraints prevent duplicate emails" do
test "identity constraints prevent duplicate emails", %{actor: actor} do
# Create first user with an email
{:ok, user1} = Accounts.create_user(%{email: "duplicate@example.com"})
{:ok, user1} = Accounts.create_user(%{email: "duplicate@example.com"}, actor: actor)
assert to_string(user1.email) == "duplicate@example.com"
# Try to create second user with same email - should fail due to unique constraint
result = Accounts.create_user(%{email: "duplicate@example.com"})
result = Accounts.create_user(%{email: "duplicate@example.com"}, actor: actor)
assert {:error, %Ash.Error.Invalid{}} = result
# Same for members
member_attrs = Map.put(@valid_member_attrs, :email, "member-dup@example.com")
{:ok, member1} = Membership.create_member(member_attrs)
{:ok, member1} = Membership.create_member(member_attrs, actor: actor)
assert member1.email == "member-dup@example.com"
# Try to create second member with same email - should fail
result2 = Membership.create_member(member_attrs)
result2 = Membership.create_member(member_attrs, actor: actor)
assert {:error, %Ash.Error.Invalid{}} = result2
end
end

View file

@ -4,121 +4,177 @@ defmodule Mv.Accounts.EmailUniquenessTest do
alias Mv.Accounts
alias Mv.Membership
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "Email uniqueness validation - Creation" do
test "CAN create member with existing unlinked user email" do
test "CAN create member with existing unlinked user email", %{actor: actor} do
# Create a user with email
{:ok, _user} =
Accounts.create_user(%{
email: "existing@example.com"
})
Accounts.create_user(
%{
email: "existing@example.com"
},
actor: actor
)
# Create member with same email - should succeed
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "existing@example.com"
})
Membership.create_member(
%{
first_name: "John",
last_name: "Doe",
email: "existing@example.com"
},
actor: actor
)
assert to_string(member.email) == "existing@example.com"
end
test "CAN create user with existing unlinked member email" do
test "CAN create user with existing unlinked member email", %{actor: actor} do
# Create a member with email
{:ok, _member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "existing@example.com"
})
Membership.create_member(
%{
first_name: "John",
last_name: "Doe",
email: "existing@example.com"
},
actor: actor
)
# Create user with same email - should succeed
{:ok, user} =
Accounts.create_user(%{
email: "existing@example.com"
})
Accounts.create_user(
%{
email: "existing@example.com"
},
actor: actor
)
assert to_string(user.email) == "existing@example.com"
end
end
describe "Email uniqueness validation - Updating unlinked entities" do
test "unlinked member email CAN be changed to an existing unlinked user email" do
test "unlinked member email CAN be changed to an existing unlinked user email", %{
actor: actor
} do
# Create a user with email
{:ok, _user} =
Accounts.create_user(%{
email: "existing_user@example.com"
})
Accounts.create_user(
%{
email: "existing_user@example.com"
},
actor: actor
)
# Create an unlinked member with different email
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "member@example.com"
})
Membership.create_member(
%{
first_name: "John",
last_name: "Doe",
email: "member@example.com"
},
actor: actor
)
# Change member email to existing user email - should succeed (member is unlinked)
{:ok, updated_member} =
Membership.update_member(member, %{
email: "existing_user@example.com"
})
Membership.update_member(
member,
%{
email: "existing_user@example.com"
},
actor: actor
)
assert to_string(updated_member.email) == "existing_user@example.com"
end
test "unlinked user email CAN be changed to an existing unlinked member email" do
test "unlinked user email CAN be changed to an existing unlinked member email", %{
actor: actor
} do
# Create a member with email
{:ok, _member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "existing_member@example.com"
})
Membership.create_member(
%{
first_name: "John",
last_name: "Doe",
email: "existing_member@example.com"
},
actor: actor
)
# Create an unlinked user with different email
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
Accounts.create_user(
%{
email: "user@example.com"
},
actor: actor
)
# Change user email to existing member email - should succeed (user is unlinked)
{:ok, updated_user} =
Accounts.update_user(user, %{
email: "existing_member@example.com"
})
Accounts.update_user(
user,
%{
email: "existing_member@example.com"
},
actor: actor
)
assert to_string(updated_user.email) == "existing_member@example.com"
end
test "unlinked member email CANNOT be changed to an existing linked user email" do
test "unlinked member email CANNOT be changed to an existing linked user email", %{
actor: actor
} do
# Create a user and link it to a member - this makes the user "linked"
{:ok, user} =
Accounts.create_user(%{
email: "linked_user@example.com"
})
Accounts.create_user(
%{
email: "linked_user@example.com"
},
actor: actor
)
{:ok, _member_a} =
Membership.create_member(%{
first_name: "Member",
last_name: "A",
email: "temp@example.com",
user: %{id: user.id}
})
Membership.create_member(
%{
first_name: "Member",
last_name: "A",
email: "temp@example.com",
user: %{id: user.id}
},
actor: actor
)
# Create an unlinked member with different email
{:ok, member_b} =
Membership.create_member(%{
first_name: "Member",
last_name: "B",
email: "member_b@example.com"
})
Membership.create_member(
%{
first_name: "Member",
last_name: "B",
email: "member_b@example.com"
},
actor: actor
)
# Try to change unlinked member's email to linked user's email - should fail
result =
Membership.update_member(member_b, %{
email: "linked_user@example.com"
})
Membership.update_member(
member_b,
%{
email: "linked_user@example.com"
},
actor: actor
)
assert {:error, %Ash.Error.Invalid{} = error} = result
@ -129,37 +185,52 @@ defmodule Mv.Accounts.EmailUniquenessTest do
end)
end
test "unlinked user email CANNOT be changed to an existing linked member email" do
test "unlinked user email CANNOT be changed to an existing linked member email", %{
actor: actor
} do
# Create a user and link it to a member - this makes the member "linked"
{:ok, user_a} =
Accounts.create_user(%{
email: "user_a@example.com"
})
Accounts.create_user(
%{
email: "user_a@example.com"
},
actor: actor
)
{:ok, _member_a} =
Membership.create_member(%{
first_name: "Member",
last_name: "A",
email: "temp@example.com",
user: %{id: user_a.id}
})
Membership.create_member(
%{
first_name: "Member",
last_name: "A",
email: "temp@example.com",
user: %{id: user_a.id}
},
actor: actor
)
# Reload user to get updated member_id and linked member email
{:ok, user_a_reloaded} = Ash.get(Mv.Accounts.User, user_a.id)
{:ok, user_a_with_member} = Ash.load(user_a_reloaded, :member)
{:ok, user_a_reloaded} = Ash.get(Mv.Accounts.User, user_a.id, actor: actor)
{:ok, user_a_with_member} = Ash.load(user_a_reloaded, :member, actor: actor)
linked_member_email = to_string(user_a_with_member.member.email)
# Create an unlinked user with different email
{:ok, user_b} =
Accounts.create_user(%{
email: "user_b@example.com"
})
Accounts.create_user(
%{
email: "user_b@example.com"
},
actor: actor
)
# Try to change unlinked user's email to linked member's email - should fail
result =
Accounts.update_user(user_b, %{
email: linked_member_email
})
Accounts.update_user(
user_b,
%{
email: linked_member_email
},
actor: actor
)
assert {:error, %Ash.Error.Invalid{} = error} = result
@ -172,28 +243,37 @@ defmodule Mv.Accounts.EmailUniquenessTest do
end
describe "Email uniqueness validation - Creating with linked emails" do
test "CANNOT create member with existing linked user email" do
test "CANNOT create member with existing linked user email", %{actor: actor} do
# Create a user and link it to a member
{:ok, user} =
Accounts.create_user(%{
email: "linked@example.com"
})
Accounts.create_user(
%{
email: "linked@example.com"
},
actor: actor
)
{:ok, _member} =
Membership.create_member(%{
first_name: "First",
last_name: "Member",
email: "temp@example.com",
user: %{id: user.id}
})
Membership.create_member(
%{
first_name: "First",
last_name: "Member",
email: "temp@example.com",
user: %{id: user.id}
},
actor: actor
)
# Try to create a new member with the linked user's email - should fail
result =
Membership.create_member(%{
first_name: "Second",
last_name: "Member",
email: "linked@example.com"
})
Membership.create_member(
%{
first_name: "Second",
last_name: "Member",
email: "linked@example.com"
},
actor: actor
)
assert {:error, %Ash.Error.Invalid{} = error} = result
@ -204,31 +284,40 @@ defmodule Mv.Accounts.EmailUniquenessTest do
end)
end
test "CANNOT create user with existing linked member email" do
test "CANNOT create user with existing linked member email", %{actor: actor} do
# Create a user and link it to a member
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
Accounts.create_user(
%{
email: "user@example.com"
},
actor: actor
)
{:ok, _member} =
Membership.create_member(%{
first_name: "Member",
last_name: "One",
email: "temp@example.com",
user: %{id: user.id}
})
Membership.create_member(
%{
first_name: "Member",
last_name: "One",
email: "temp@example.com",
user: %{id: user.id}
},
actor: actor
)
# Reload user to get the linked member's email
{:ok, user_reloaded} = Ash.get(Mv.Accounts.User, user.id)
{:ok, user_with_member} = Ash.load(user_reloaded, :member)
{:ok, user_reloaded} = Ash.get(Mv.Accounts.User, user.id, actor: actor)
{:ok, user_with_member} = Ash.load(user_reloaded, :member, actor: actor)
linked_member_email = to_string(user_with_member.member.email)
# Try to create a new user with the linked member's email - should fail
result =
Accounts.create_user(%{
email: linked_member_email
})
Accounts.create_user(
%{
email: linked_member_email
},
actor: actor
)
assert {:error, %Ash.Error.Invalid{} = error} = result
@ -241,32 +330,45 @@ defmodule Mv.Accounts.EmailUniquenessTest do
end
describe "Email uniqueness validation - Updating linked entities" do
test "linked member email CANNOT be changed to an existing user email" do
test "linked member email CANNOT be changed to an existing user email", %{actor: actor} do
# Create a user with email
{:ok, _other_user} =
Accounts.create_user(%{
email: "other_user@example.com"
})
Accounts.create_user(
%{
email: "other_user@example.com"
},
actor: actor
)
# Create a user and link it to a member
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
Accounts.create_user(
%{
email: "user@example.com"
},
actor: actor
)
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "temp@example.com",
user: %{id: user.id}
})
Membership.create_member(
%{
first_name: "John",
last_name: "Doe",
email: "temp@example.com",
user: %{id: user.id}
},
actor: actor
)
# Try to change linked member's email to other user's email - should fail
result =
Membership.update_member(member, %{
email: "other_user@example.com"
})
Membership.update_member(
member,
%{
email: "other_user@example.com"
},
actor: actor
)
assert {:error, %Ash.Error.Invalid{} = error} = result
@ -277,37 +379,50 @@ defmodule Mv.Accounts.EmailUniquenessTest do
end)
end
test "linked user email CANNOT be changed to an existing member email" do
test "linked user email CANNOT be changed to an existing member email", %{actor: actor} do
# Create a member with email
{:ok, _other_member} =
Membership.create_member(%{
first_name: "Jane",
last_name: "Doe",
email: "other_member@example.com"
})
Membership.create_member(
%{
first_name: "Jane",
last_name: "Doe",
email: "other_member@example.com"
},
actor: actor
)
# Create a user and link it to a member
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
Accounts.create_user(
%{
email: "user@example.com"
},
actor: actor
)
{:ok, _member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "temp@example.com",
user: %{id: user.id}
})
Membership.create_member(
%{
first_name: "John",
last_name: "Doe",
email: "temp@example.com",
user: %{id: user.id}
},
actor: actor
)
# Reload user to get updated member_id
{:ok, user_reloaded} = Ash.get(Mv.Accounts.User, user.id)
{:ok, user_reloaded} = Ash.get(Mv.Accounts.User, user.id, actor: actor)
# Try to change linked user's email to other member's email - should fail
result =
Accounts.update_user(user_reloaded, %{
email: "other_member@example.com"
})
Accounts.update_user(
user_reloaded,
%{
email: "other_member@example.com"
},
actor: actor
)
assert {:error, %Ash.Error.Invalid{} = error} = result
@ -320,34 +435,49 @@ defmodule Mv.Accounts.EmailUniquenessTest do
end
describe "Email uniqueness validation - Linking" do
test "CANNOT link user to member if user email is already used by another unlinked member" do
test "CANNOT link user to member if user email is already used by another unlinked member", %{
actor: actor
} do
# Create a member with email
{:ok, _other_member} =
Membership.create_member(%{
first_name: "Jane",
last_name: "Doe",
email: "duplicate@example.com"
})
Membership.create_member(
%{
first_name: "Jane",
last_name: "Doe",
email: "duplicate@example.com"
},
actor: actor
)
# Create a user with same email
{:ok, user} =
Accounts.create_user(%{
email: "duplicate@example.com"
})
Accounts.create_user(
%{
email: "duplicate@example.com"
},
actor: actor
)
# Create a member to link with the user
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Smith",
email: "john@example.com"
})
Membership.create_member(
%{
first_name: "John",
last_name: "Smith",
email: "john@example.com"
},
actor: actor
)
# Try to link user to member - should fail because user.email is already used by other_member
result =
Accounts.update_user(user, %{
member: %{id: member.id}
})
Accounts.update_user(
user,
%{
member: %{id: member.id}
},
actor: actor
)
assert {:error, %Ash.Error.Invalid{} = error} = result
@ -358,120 +488,160 @@ defmodule Mv.Accounts.EmailUniquenessTest do
end)
end
test "CAN link member to user even if member email is used by another user (member email gets overridden)" do
test "CAN link member to user even if member email is used by another user (member email gets overridden)",
%{actor: actor} do
# Create a user with email
{:ok, _other_user} =
Accounts.create_user(%{
email: "duplicate@example.com"
})
Accounts.create_user(
%{
email: "duplicate@example.com"
},
actor: actor
)
# Create a member with same email
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "duplicate@example.com"
})
Membership.create_member(
%{
first_name: "John",
last_name: "Doe",
email: "duplicate@example.com"
},
actor: actor
)
# Create a user to link with the member
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
Accounts.create_user(
%{
email: "user@example.com"
},
actor: actor
)
# Link member to user - should succeed because member.email will be overridden
{:ok, updated_member} =
Membership.update_member(member, %{
user: %{id: user.id}
})
Membership.update_member(
member,
%{
user: %{id: user.id}
},
actor: actor
)
# Member email should now be the same as user email
{:ok, member_reloaded} = Ash.get(Mv.Membership.Member, updated_member.id)
{:ok, member_reloaded} = Ash.get(Mv.Membership.Member, updated_member.id, actor: actor)
assert to_string(member_reloaded.email) == "user@example.com"
end
end
describe "Email syncing" do
test "member email syncs to linked user email without validation error" do
test "member email syncs to linked user email without validation error", %{actor: actor} do
# Create a user
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
Accounts.create_user(
%{
email: "user@example.com"
},
actor: actor
)
# Create a member linked to this user
# The override change will set member.email = user.email automatically
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "member@example.com",
user: %{id: user.id}
})
Membership.create_member(
%{
first_name: "John",
last_name: "Doe",
email: "member@example.com",
user: %{id: user.id}
},
actor: actor
)
# Member email should have been overridden to user email
# This happens through our sync mechanism, which should NOT trigger
# the "email already used" validation because it's the same user
{:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id)
{:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
assert member_after_link.email == "user@example.com"
end
test "user email syncs to linked member without validation error" do
test "user email syncs to linked member without validation error", %{actor: actor} do
# Create a member
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "member@example.com"
})
Membership.create_member(
%{
first_name: "John",
last_name: "Doe",
email: "member@example.com"
},
actor: actor
)
# Create a user linked to this member
# The override change will set member.email = user.email automatically
{:ok, _user} =
Accounts.create_user(%{
email: "user@example.com",
member: %{id: member.id}
})
Accounts.create_user(
%{
email: "user@example.com",
member: %{id: member.id}
},
actor: actor
)
# Member email should have been overridden to user email
# This happens through our sync mechanism, which should NOT trigger
# the "email already used" validation because it's the same member
{:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id)
{:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
assert member_after_link.email == "user@example.com"
end
test "two unlinked users cannot have the same email" do
test "two unlinked users cannot have the same email", %{actor: actor} do
# Create first user
{:ok, _user1} =
Accounts.create_user(%{
email: "duplicate@example.com"
})
Accounts.create_user(
%{
email: "duplicate@example.com"
},
actor: actor
)
# Try to create second user with same email
result =
Accounts.create_user(%{
email: "duplicate@example.com"
})
Accounts.create_user(
%{
email: "duplicate@example.com"
},
actor: actor
)
assert {:error, %Ash.Error.Invalid{}} = result
end
test "two unlinked members cannot have the same email (members have unique constraint)" do
test "two unlinked members cannot have the same email (members have unique constraint)", %{
actor: actor
} do
# Create first member
{:ok, _member1} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "duplicate@example.com"
})
Membership.create_member(
%{
first_name: "John",
last_name: "Doe",
email: "duplicate@example.com"
},
actor: actor
)
# Try to create second member with same email - should fail
result =
Membership.create_member(%{
first_name: "Jane",
last_name: "Smith",
email: "duplicate@example.com"
})
Membership.create_member(
%{
first_name: "Jane",
last_name: "Smith",
email: "duplicate@example.com"
},
actor: actor
)
assert {:error, %Ash.Error.Invalid{}} = result
# Members DO have a unique email constraint at database level

View file

@ -10,6 +10,11 @@ defmodule Mv.Accounts.UserAuthenticationTest do
use MvWeb.ConnCase, async: true
require Ash.Query
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "Password authentication user identification" do
@tag :test_proposal
test "password login uses email as identifier" do
@ -27,7 +32,7 @@ defmodule Mv.Accounts.UserAuthenticationTest do
{:ok, users} =
Mv.Accounts.User
|> Ash.Query.filter(email == ^email_to_find)
|> Ash.read()
|> Ash.read(actor: user)
assert length(users) == 1
found_user = List.first(users)
@ -113,11 +118,16 @@ defmodule Mv.Accounts.UserAuthenticationTest do
# Use sign_in_with_rauthy to find user by oidc_id
# Note: This test will FAIL until we implement the security fix
# that changes the filter from email to oidc_id
system_actor = Mv.Helpers.SystemActor.get_system_actor()
result =
Mv.Accounts.read_sign_in_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.read_sign_in_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: system_actor
)
case result do
{:ok, [found_user]} ->
@ -141,11 +151,16 @@ defmodule Mv.Accounts.UserAuthenticationTest do
}
# Should create via register_with_rauthy
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, new_user} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: system_actor
)
assert to_string(new_user.email) == "newuser@example.com"
assert new_user.oidc_id == "brand_new_oidc_789"
@ -170,12 +185,12 @@ defmodule Mv.Accounts.UserAuthenticationTest do
{:ok, users1} =
Mv.Accounts.User
|> Ash.Query.filter(oidc_id == "oidc_unique_1")
|> Ash.read()
|> Ash.read(actor: user1)
{:ok, users2} =
Mv.Accounts.User
|> Ash.Query.filter(oidc_id == "oidc_unique_2")
|> Ash.read()
|> Ash.read(actor: user2)
assert length(users1) == 1
assert length(users2) == 1
@ -205,11 +220,16 @@ defmodule Mv.Accounts.UserAuthenticationTest do
}
# Should NOT find the user (security requirement)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
result =
Mv.Accounts.read_sign_in_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.read_sign_in_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: system_actor
)
# Either returns empty list OR authentication error - both mean "user not found"
case result do
@ -241,11 +261,16 @@ defmodule Mv.Accounts.UserAuthenticationTest do
}
# Should NOT find the user because oidc_id is nil
system_actor = Mv.Helpers.SystemActor.get_system_actor()
result =
Mv.Accounts.read_sign_in_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.read_sign_in_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: system_actor
)
# Either returns empty list OR authentication error - both mean "user not found"
case result do

View file

@ -8,6 +8,11 @@ defmodule Mv.Accounts.UserEmailSyncTest do
alias Mv.Accounts
alias Mv.Membership
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "User email synchronization to linked Member" do
@valid_user_attrs %{
email: "user@example.com"
@ -19,96 +24,100 @@ defmodule Mv.Accounts.UserEmailSyncTest do
email: "member@example.com"
}
test "updating user email syncs to linked member" do
test "updating user email syncs to linked member", %{actor: actor} do
# Create a member
{:ok, member} = Membership.create_member(@valid_member_attrs)
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
assert member.email == "member@example.com"
# Create a user linked to the member
{:ok, user} =
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}), actor: actor)
# Verify initial state - member email should be overridden by user email
{:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id)
{:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
assert member_after_link.email == "user@example.com"
# Update user email
{:ok, updated_user} = Accounts.update_user(user, %{email: "newemail@example.com"})
{:ok, updated_user} =
Accounts.update_user(user, %{email: "newemail@example.com"}, actor: actor)
assert to_string(updated_user.email) == "newemail@example.com"
# Verify member email was also updated
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
assert synced_member.email == "newemail@example.com"
end
test "creating user linked to member overrides member email" do
test "creating user linked to member overrides member email", %{actor: actor} do
# Create a member with their own email
{:ok, member} = Membership.create_member(@valid_member_attrs)
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
assert member.email == "member@example.com"
# Create a user linked to this member
{:ok, user} =
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}), actor: actor)
assert to_string(user.email) == "user@example.com"
assert user.member_id == member.id
# Verify member email was overridden with user email
{:ok, updated_member} = Ash.get(Mv.Membership.Member, member.id)
{:ok, updated_member} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
assert updated_member.email == "user@example.com"
end
test "linking user to existing member syncs user email to member" do
test "linking user to existing member syncs user email to member", %{actor: actor} do
# Create a standalone member
{:ok, member} = Membership.create_member(@valid_member_attrs)
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
assert member.email == "member@example.com"
# Create a standalone user
{:ok, user} = Accounts.create_user(@valid_user_attrs)
{:ok, user} = Accounts.create_user(@valid_user_attrs, actor: actor)
assert to_string(user.email) == "user@example.com"
assert user.member_id == nil
# Link the user to the member
{:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member.id}})
{:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member.id}}, actor: actor)
assert linked_user.member_id == member.id
# Verify member email was overridden with user email
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
assert synced_member.email == "user@example.com"
end
test "updating user email when no member linked does not error" do
test "updating user email when no member linked does not error", %{actor: actor} do
# Create a standalone user without member link
{:ok, user} = Accounts.create_user(@valid_user_attrs)
{:ok, user} = Accounts.create_user(@valid_user_attrs, actor: actor)
assert to_string(user.email) == "user@example.com"
assert user.member_id == nil
# Update user email - should work fine without error
{:ok, updated_user} = Accounts.update_user(user, %{email: "newemail@example.com"})
{:ok, updated_user} =
Accounts.update_user(user, %{email: "newemail@example.com"}, actor: actor)
assert to_string(updated_user.email) == "newemail@example.com"
assert updated_user.member_id == nil
end
test "unlinking user from member does not sync email" do
test "unlinking user from member does not sync email", %{actor: actor} do
# Create member
{:ok, member} = Membership.create_member(@valid_member_attrs)
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
# Create user linked to member
{:ok, user} =
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}), actor: actor)
assert user.member_id == member.id
# Verify member email was synced
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
assert synced_member.email == "user@example.com"
# Unlink user from member
{:ok, unlinked_user} = Accounts.update_user(user, %{member: nil})
{:ok, unlinked_user} = Accounts.update_user(user, %{member: nil}, actor: actor)
assert unlinked_user.member_id == nil
# Member email should remain unchanged after unlinking
{:ok, member_after_unlink} = Ash.get(Mv.Membership.Member, member.id)
{:ok, member_after_unlink} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
assert member_after_unlink.email == "user@example.com"
end
end
@ -119,6 +128,8 @@ defmodule Mv.Accounts.UserEmailSyncTest do
email = "test@example.com"
password = "securepassword123"
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create user with password strategy (simulating registration)
{:ok, user} =
Mv.Accounts.User
@ -126,7 +137,7 @@ defmodule Mv.Accounts.UserEmailSyncTest do
email: email,
password: password
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert to_string(user.email) == email
assert user.hashed_password != nil
@ -138,7 +149,7 @@ defmodule Mv.Accounts.UserEmailSyncTest do
email: email,
password: password
})
|> Ash.read_one()
|> Ash.read_one(actor: system_actor)
assert signed_in_user.id == user.id
assert to_string(signed_in_user.email) == email
@ -153,6 +164,8 @@ defmodule Mv.Accounts.UserEmailSyncTest do
oauth_tokens = %{"access_token" => "mock_token"}
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Simulate OIDC registration
{:ok, user} =
Mv.Accounts.User
@ -160,7 +173,7 @@ defmodule Mv.Accounts.UserEmailSyncTest do
user_info: user_info,
oauth_tokens: oauth_tokens
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert to_string(user.email) == "oidc@example.com"
assert user.oidc_id == "oidc-user-123"

View file

@ -18,71 +18,86 @@ defmodule Mv.Accounts.UserMemberDeletionTest do
email: "john@example.com"
}
test "deleting a member sets the user's member_id to NULL" do
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
test "deleting a member sets the user's member_id to NULL", %{actor: actor} do
# Create a member
{:ok, member} = Membership.create_member(@valid_member_attrs)
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
# Create a user linked to the member
{:ok, user} =
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}), actor: actor)
# Verify the relationship is established
{:ok, user_before_delete} = Ash.get(Mv.Accounts.User, user.id, load: [:member])
{:ok, user_before_delete} =
Ash.get(Mv.Accounts.User, user.id, actor: actor, load: [:member], actor: actor)
assert user_before_delete.member_id == member.id
assert user_before_delete.member.id == member.id
# Delete the member
:ok = Membership.destroy_member(member)
:ok = Membership.destroy_member(member, actor: actor)
# Verify the user still exists but member_id is NULL
{:ok, user_after_delete} = Ash.get(Mv.Accounts.User, user.id, load: [:member])
{:ok, user_after_delete} =
Ash.get(Mv.Accounts.User, user.id, actor: actor, load: [:member], actor: actor)
assert user_after_delete.id == user.id
assert user_after_delete.member_id == nil
assert user_after_delete.member == nil
end
test "user can be linked to a new member after old member is deleted" do
test "user can be linked to a new member after old member is deleted", %{actor: actor} do
# Create first member
{:ok, member1} = Membership.create_member(@valid_member_attrs)
{:ok, member1} = Membership.create_member(@valid_member_attrs, actor: actor)
# Create user linked to first member
{:ok, user} =
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member1.id}))
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member1.id}), actor: actor)
assert user.member_id == member1.id
# Delete first member
:ok = Membership.destroy_member(member1)
:ok = Membership.destroy_member(member1, actor: actor)
# Reload user from database to get updated member_id (should be NULL)
{:ok, user_after_delete} = Ash.get(Mv.Accounts.User, user.id)
{:ok, user_after_delete} = Ash.get(Mv.Accounts.User, user.id, actor: actor)
assert user_after_delete.member_id == nil
# Create second member
{:ok, member2} =
Membership.create_member(%{
first_name: "Jane",
last_name: "Smith",
email: "jane@example.com"
})
Membership.create_member(
%{
first_name: "Jane",
last_name: "Smith",
email: "jane@example.com"
},
actor: actor
)
# Link user to second member (use reloaded user)
{:ok, updated_user} = Accounts.update_user(user_after_delete, %{member: %{id: member2.id}})
{:ok, updated_user} =
Accounts.update_user(user_after_delete, %{member: %{id: member2.id}}, actor: actor)
# Verify new relationship
{:ok, final_user} = Ash.get(Mv.Accounts.User, updated_user.id, load: [:member])
{:ok, final_user} =
Ash.get(Mv.Accounts.User, updated_user.id, actor: actor, load: [:member])
assert final_user.member_id == member2.id
assert final_user.member.id == member2.id
end
test "member without linked user can be deleted normally" do
{:ok, member} = Membership.create_member(@valid_member_attrs)
test "member without linked user can be deleted normally", %{actor: actor} do
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
# Delete member (no users linked)
assert :ok = Membership.destroy_member(member)
assert :ok = Membership.destroy_member(member, actor: actor)
# Verify member is deleted
assert {:error, _} = Ash.get(Mv.Membership.Member, member.id)
assert {:error, _} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
end
end
end

View file

@ -10,51 +10,70 @@ defmodule Mv.Accounts.UserMemberLinkingEmailTest do
alias Mv.Accounts
alias Mv.Membership
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "link with same email" do
test "succeeds when user.email == member.email" do
test "succeeds when user.email == member.email", %{actor: actor} do
# Create member with specific email
{:ok, member} =
Membership.create_member(%{
first_name: "Alice",
last_name: "Johnson",
email: "alice@example.com"
})
Membership.create_member(
%{
first_name: "Alice",
last_name: "Johnson",
email: "alice@example.com"
},
actor: actor
)
# Create user with same email and link to member
result =
Accounts.create_user(%{
email: "alice@example.com",
member: %{id: member.id}
})
Accounts.create_user(
%{
email: "alice@example.com",
member: %{id: member.id}
},
actor: actor
)
# Should succeed without errors
assert {:ok, user} = result
assert to_string(user.email) == "alice@example.com"
# Reload to verify link
user = Ash.load!(user, [:member], domain: Mv.Accounts)
user = Ash.load!(user, [:member], domain: Mv.Accounts, actor: actor)
assert user.member.id == member.id
assert user.member.email == "alice@example.com"
end
test "no validation error triggered when updating linked pair with same email" do
test "no validation error triggered when updating linked pair with same email", %{
actor: actor
} do
# Create member
{:ok, member} =
Membership.create_member(%{
first_name: "Bob",
last_name: "Smith",
email: "bob@example.com"
})
Membership.create_member(
%{
first_name: "Bob",
last_name: "Smith",
email: "bob@example.com"
},
actor: actor
)
# Create user and link
{:ok, user} =
Accounts.create_user(%{
email: "bob@example.com",
member: %{id: member.id}
})
Accounts.create_user(
%{
email: "bob@example.com",
member: %{id: member.id}
},
actor: actor
)
# Update user (should not trigger email validation error)
result = Accounts.update_user(user, %{email: "bob@example.com"})
result = Accounts.update_user(user, %{email: "bob@example.com"}, actor: actor)
assert {:ok, updated_user} = result
assert to_string(updated_user.email) == "bob@example.com"
@ -62,70 +81,88 @@ defmodule Mv.Accounts.UserMemberLinkingEmailTest do
end
describe "link with different emails" do
test "fails if member.email is used by a DIFFERENT linked user" do
test "fails if member.email is used by a DIFFERENT linked user", %{actor: actor} do
# Create first user and link to a different member
{:ok, other_member} =
Membership.create_member(%{
first_name: "Other",
last_name: "Member",
email: "other@example.com"
})
Membership.create_member(
%{
first_name: "Other",
last_name: "Member",
email: "other@example.com"
},
actor: actor
)
{:ok, _user1} =
Accounts.create_user(%{
email: "user1@example.com",
member: %{id: other_member.id}
})
Accounts.create_user(
%{
email: "user1@example.com",
member: %{id: other_member.id}
},
actor: actor
)
# Reload to ensure email sync happened
_other_member = Ash.reload!(other_member)
_other_member = Ash.reload!(other_member, actor: actor)
# Create a NEW member with different email
{:ok, member} =
Membership.create_member(%{
first_name: "Charlie",
last_name: "Brown",
email: "charlie@example.com"
})
Membership.create_member(
%{
first_name: "Charlie",
last_name: "Brown",
email: "charlie@example.com"
},
actor: actor
)
# Try to create user2 with email that matches the linked other_member
result =
Accounts.create_user(%{
email: "user1@example.com",
member: %{id: member.id}
})
Accounts.create_user(
%{
email: "user1@example.com",
member: %{id: member.id}
},
actor: actor
)
# Should fail because user1@example.com is already used by other_member (which is linked to user1)
assert {:error, _error} = result
end
test "succeeds for unique emails" do
test "succeeds for unique emails", %{actor: actor} do
# Create member
{:ok, member} =
Membership.create_member(%{
first_name: "David",
last_name: "Wilson",
email: "david@example.com"
})
Membership.create_member(
%{
first_name: "David",
last_name: "Wilson",
email: "david@example.com"
},
actor: actor
)
# Create user with different but unique email
result =
Accounts.create_user(%{
email: "user@example.com",
member: %{id: member.id}
})
Accounts.create_user(
%{
email: "user@example.com",
member: %{id: member.id}
},
actor: actor
)
# Should succeed
assert {:ok, user} = result
# Email sync should update member's email to match user's
user = Ash.load!(user, [:member], domain: Mv.Accounts)
user = Ash.load!(user, [:member], domain: Mv.Accounts, actor: actor)
assert user.member.email == "user@example.com"
end
end
describe "edge cases" do
test "unlinking and relinking with same email works (Problem #4)" do
test "unlinking and relinking with same email works (Problem #4)", %{actor: actor} do
# This is the exact scenario from Problem #4:
# 1. Link user and member (both have same email)
# 2. Unlink them (member keeps the email)
@ -133,34 +170,40 @@ defmodule Mv.Accounts.UserMemberLinkingEmailTest do
# Create member
{:ok, member} =
Membership.create_member(%{
first_name: "Emma",
last_name: "Davis",
email: "emma@example.com"
})
Membership.create_member(
%{
first_name: "Emma",
last_name: "Davis",
email: "emma@example.com"
},
actor: actor
)
# Create user and link
{:ok, user} =
Accounts.create_user(%{
email: "emma@example.com",
member: %{id: member.id}
})
Accounts.create_user(
%{
email: "emma@example.com",
member: %{id: member.id}
},
actor: actor
)
# Verify they are linked
user = Ash.load!(user, [:member], domain: Mv.Accounts)
user = Ash.load!(user, [:member], domain: Mv.Accounts, actor: actor)
assert user.member.id == member.id
assert user.member.email == "emma@example.com"
# Unlink
{:ok, unlinked_user} = Accounts.update_user(user, %{member: nil})
{:ok, unlinked_user} = Accounts.update_user(user, %{member: nil}, actor: actor)
assert is_nil(unlinked_user.member_id)
# Member still has the email after unlink
member = Ash.reload!(member)
member = Ash.reload!(member, actor: actor)
assert member.email == "emma@example.com"
# Relink (should work - this is Problem #4)
result = Accounts.update_user(unlinked_user, %{member: %{id: member.id}})
result = Accounts.update_user(unlinked_user, %{member: %{id: member.id}}, actor: actor)
assert {:ok, relinked_user} = result
assert relinked_user.member_id == member.id

View file

@ -9,121 +9,150 @@ defmodule Mv.Accounts.UserMemberLinkingTest do
alias Mv.Accounts
alias Mv.Membership
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "User-Member Linking with Email Sync" do
test "link user to member with different email syncs member email" do
test "link user to member with different email syncs member email", %{actor: actor} do
# Create user with one email
{:ok, user} = Accounts.create_user(%{email: "user@example.com"})
{:ok, user} = Accounts.create_user(%{email: "user@example.com"}, actor: actor)
# Create member with different email
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "member@example.com"
})
Membership.create_member(
%{
first_name: "John",
last_name: "Doe",
email: "member@example.com"
},
actor: actor
)
# Link user to member
{:ok, updated_user} = Accounts.update_user(user, %{member: %{id: member.id}})
{:ok, updated_user} = Accounts.update_user(user, %{member: %{id: member.id}}, actor: actor)
# Verify link exists
user_with_member = Ash.get!(Mv.Accounts.User, updated_user.id, load: [:member])
user_with_member =
Ash.get!(Mv.Accounts.User, updated_user.id, actor: actor, load: [:member])
assert user_with_member.member.id == member.id
# Verify member email was synced to match user email
synced_member = Ash.get!(Mv.Membership.Member, member.id)
synced_member = Ash.get!(Mv.Membership.Member, member.id, actor: actor)
assert synced_member.email == "user@example.com"
end
test "unlink member from user sets member to nil" do
test "unlink member from user sets member to nil", %{actor: actor} do
# Create and link user and member
{:ok, user} = Accounts.create_user(%{email: "user@example.com"})
{:ok, user} = Accounts.create_user(%{email: "user@example.com"}, actor: actor)
{:ok, member} =
Membership.create_member(%{
first_name: "Jane",
last_name: "Smith",
email: "jane@example.com"
})
Membership.create_member(
%{
first_name: "Jane",
last_name: "Smith",
email: "jane@example.com"
},
actor: actor
)
{:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member.id}})
{:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member.id}}, actor: actor)
# Verify link exists
user_with_member = Ash.get!(Mv.Accounts.User, linked_user.id, load: [:member])
user_with_member = Ash.get!(Mv.Accounts.User, linked_user.id, actor: actor, load: [:member])
assert user_with_member.member.id == member.id
# Unlink by setting member to nil
{:ok, unlinked_user} = Accounts.update_user(linked_user, %{member: nil})
{:ok, unlinked_user} = Accounts.update_user(linked_user, %{member: nil}, actor: actor)
# Verify link is removed
user_without_member = Ash.get!(Mv.Accounts.User, unlinked_user.id, load: [:member])
user_without_member =
Ash.get!(Mv.Accounts.User, unlinked_user.id, actor: actor, load: [:member])
assert is_nil(user_without_member.member)
# Verify member still exists independently
member_still_exists = Ash.get!(Mv.Membership.Member, member.id)
member_still_exists = Ash.get!(Mv.Membership.Member, member.id, actor: actor)
assert member_still_exists.id == member.id
end
test "cannot link member already linked to another user" do
test "cannot link member already linked to another user", %{actor: actor} do
# Create first user and link to member
{:ok, user1} = Accounts.create_user(%{email: "user1@example.com"})
{:ok, user1} = Accounts.create_user(%{email: "user1@example.com"}, actor: actor)
{:ok, member} =
Membership.create_member(%{
first_name: "Bob",
last_name: "Wilson",
email: "bob@example.com"
})
Membership.create_member(
%{
first_name: "Bob",
last_name: "Wilson",
email: "bob@example.com"
},
actor: actor
)
{:ok, _linked_user1} = Accounts.update_user(user1, %{member: %{id: member.id}})
{:ok, _linked_user1} =
Accounts.update_user(user1, %{member: %{id: member.id}}, actor: actor)
# Create second user and try to link to same member
{:ok, user2} = Accounts.create_user(%{email: "user2@example.com"})
{:ok, user2} = Accounts.create_user(%{email: "user2@example.com"}, actor: actor)
# Should fail because member is already linked
assert {:error, %Ash.Error.Invalid{}} =
Accounts.update_user(user2, %{member: %{id: member.id}})
Accounts.update_user(user2, %{member: %{id: member.id}}, actor: actor)
end
test "cannot change member link directly, must unlink first" do
test "cannot change member link directly, must unlink first", %{actor: actor} do
# Create user and link to first member
{:ok, user} = Accounts.create_user(%{email: "user@example.com"})
{:ok, user} = Accounts.create_user(%{email: "user@example.com"}, actor: actor)
{:ok, member1} =
Membership.create_member(%{
first_name: "Alice",
last_name: "Johnson",
email: "alice@example.com"
})
Membership.create_member(
%{
first_name: "Alice",
last_name: "Johnson",
email: "alice@example.com"
},
actor: actor
)
{:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member1.id}})
{:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member1.id}}, actor: actor)
# Create second member
{:ok, member2} =
Membership.create_member(%{
first_name: "Charlie",
last_name: "Brown",
email: "charlie@example.com"
})
Membership.create_member(
%{
first_name: "Charlie",
last_name: "Brown",
email: "charlie@example.com"
},
actor: actor
)
# Try to directly change member link (should fail)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Accounts.update_user(linked_user, %{member: %{id: member2.id}})
Accounts.update_user(linked_user, %{member: %{id: member2.id}}, actor: actor)
# Verify error message mentions "Remove existing member first"
error_messages = Enum.map(errors, & &1.message)
assert Enum.any?(error_messages, &String.contains?(&1, "Remove existing member first"))
# Two-step process: first unlink, then link new member
{:ok, unlinked_user} = Accounts.update_user(linked_user, %{member: nil})
{:ok, unlinked_user} = Accounts.update_user(linked_user, %{member: nil}, actor: actor)
# After unlinking, member1 still has the user's email
# Change member1's email to avoid conflict when relinking to member2
{:ok, _} = Membership.update_member(member1, %{email: "alice_changed@example.com"})
{:ok, _} =
Membership.update_member(member1, %{email: "alice_changed@example.com"}, actor: actor)
{:ok, relinked_user} = Accounts.update_user(unlinked_user, %{member: %{id: member2.id}})
{:ok, relinked_user} =
Accounts.update_user(unlinked_user, %{member: %{id: member2.id}}, actor: actor)
# Verify new link is established
user_with_new_member = Ash.get!(Mv.Accounts.User, relinked_user.id, load: [:member])
user_with_new_member =
Ash.get!(Mv.Accounts.User, relinked_user.id, actor: actor, load: [:member])
assert user_with_new_member.member.id == member2.id
end
end

View file

@ -5,6 +5,11 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do
alias Mv.Accounts
alias Mv.Membership
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "User-Member Relationship - Basic Tests" do
@valid_user_attrs %{
email: "test@example.com"
@ -16,22 +21,26 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do
email: "john@example.com"
}
test "user can exist without member" do
{:ok, user} = Accounts.create_user(@valid_user_attrs)
test "user can exist without member", %{actor: actor} do
{:ok, user} = Accounts.create_user(@valid_user_attrs, actor: actor)
assert user.member_id == nil
# Load the relationship to test it
{:ok, user_with_member} = Ash.get(Mv.Accounts.User, user.id, load: [:member])
{:ok, user_with_member} =
Ash.get(Mv.Accounts.User, user.id, actor: actor, load: [:member], actor: actor)
assert user_with_member.member == nil
end
test "member can exist without user" do
{:ok, member} = Membership.create_member(@valid_member_attrs)
test "member can exist without user", %{actor: actor} do
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
assert member.id != nil
assert member.first_name == "John"
# Load the relationship to test it
{:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
{:ok, member_with_user} =
Ash.get(Mv.Membership.Member, member.id, actor: actor, load: [:user], actor: actor)
assert member_with_user.user == nil
end
end
@ -47,47 +56,58 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do
email: "alice@example.com"
}
test "user can be linked to member during user creation" do
{:ok, member} = Membership.create_member(@valid_member_attrs)
test "user can be linked to member during user creation", %{actor: actor} do
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
user_attrs = Map.put(@valid_user_attrs, :member, %{id: member.id})
{:ok, user} = Accounts.create_user(user_attrs)
{:ok, user} = Accounts.create_user(user_attrs, actor: actor)
# Load the relationship to test it
{:ok, user_with_member} = Ash.get(Mv.Accounts.User, user.id, load: [:member])
{:ok, user_with_member} =
Ash.get(Mv.Accounts.User, user.id, actor: actor, load: [:member], actor: actor)
assert user_with_member.member.id == member.id
end
test "member can be linked to user during member creation using manage_relationship" do
{:ok, user} = Accounts.create_user(@valid_user_attrs)
test "member can be linked to user during member creation using manage_relationship", %{
actor: actor
} do
{:ok, user} = Accounts.create_user(@valid_user_attrs, actor: actor)
member_attrs = Map.put(@valid_member_attrs, :user, %{id: user.id})
{:ok, member} = Membership.create_member(member_attrs)
{:ok, member} = Membership.create_member(member_attrs, actor: actor)
# Load the relationship to test it
{:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
{:ok, member_with_user} =
Ash.get(Mv.Membership.Member, member.id, actor: actor, load: [:user], actor: actor)
assert member_with_user.user.id == user.id
end
test "user can be linked to member during update" do
{:ok, user} = Accounts.create_user(@valid_user_attrs)
{:ok, member} = Membership.create_member(@valid_member_attrs)
test "user can be linked to member during update", %{actor: actor} do
{:ok, user} = Accounts.create_user(@valid_user_attrs, actor: actor)
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
{:ok, updated_user} = Accounts.update_user(user, %{member: %{id: member.id}})
{:ok, updated_user} = Accounts.update_user(user, %{member: %{id: member.id}}, actor: actor)
# Load the relationship to test it
{:ok, user_with_member} = Ash.get(Mv.Accounts.User, updated_user.id, load: [:member])
{:ok, user_with_member} =
Ash.get(Mv.Accounts.User, updated_user.id, actor: actor, load: [:member], actor: actor)
assert user_with_member.member.id == member.id
end
test "member can be linked to user during update using manage_relationship" do
{:ok, user} = Accounts.create_user(@valid_user_attrs)
{:ok, member} = Membership.create_member(@valid_member_attrs)
test "member can be linked to user during update using manage_relationship", %{actor: actor} do
{:ok, user} = Accounts.create_user(@valid_user_attrs, actor: actor)
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
{:ok, _updated_member} = Membership.update_member(member, %{user: %{id: user.id}})
{:ok, _updated_member} =
Membership.update_member(member, %{user: %{id: user.id}}, actor: actor)
# Load the relationship to test it
{:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
{:ok, member_with_user} =
Ash.get(Mv.Membership.Member, member.id, actor: actor, load: [:user], actor: actor)
assert member_with_user.user.id == user.id
end
end
@ -103,25 +123,39 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do
email: "bob@example.com"
}
test "ash resolves inverse relationship automatically" do
{:ok, member} = Membership.create_member(@valid_member_attrs)
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
test "ash resolves inverse relationship automatically", %{actor: actor} do
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
user_attrs = Map.put(@valid_user_attrs, :member, %{id: member.id})
{:ok, user} = Accounts.create_user(user_attrs)
{:ok, user} = Accounts.create_user(user_attrs, actor: actor)
# Load relationships
{:ok, user_with_member} = Ash.get(Mv.Accounts.User, user.id, load: [:member])
{:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
{:ok, user_with_member} =
Ash.get(Mv.Accounts.User, user.id, actor: actor, load: [:member], actor: actor)
{:ok, member_with_user} =
Ash.get(Mv.Membership.Member, member.id, actor: actor, load: [:user], actor: actor)
assert user_with_member.member.id == member.id
assert member_with_user.user.id == user.id
end
test "member can find associated user" do
{:ok, member} = Membership.create_member(@valid_member_attrs)
test "member can find associated user", %{actor: actor} do
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
{:ok, user} =
Accounts.create_user(%{email: "test3@example.com", member: %{id: member.id}},
actor: actor
)
{:ok, member_with_user} =
Ash.get(Mv.Membership.Member, member.id, actor: actor, load: [:user], actor: actor)
{:ok, user} = Accounts.create_user(%{email: "test3@example.com", member: %{id: member.id}})
{:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
assert member_with_user.user.id == user.id
end
end
@ -137,61 +171,77 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do
email: "charlie@example.com"
}
test "prevents overwriting a member of already linked user on update" do
{:ok, existing_member} = Membership.create_member(@valid_member_attrs)
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
test "prevents overwriting a member of already linked user on update", %{actor: actor} do
{:ok, existing_member} = Membership.create_member(@valid_member_attrs, actor: actor)
user_attrs = Map.put(@valid_user_attrs, :member, %{id: existing_member.id})
{:ok, user} = Accounts.create_user(user_attrs)
{:ok, user} = Accounts.create_user(user_attrs, actor: actor)
{:ok, member2} =
Membership.create_member(%{
first_name: "Dave",
last_name: "Wilson",
email: "dave@example.com"
})
Membership.create_member(
%{
first_name: "Dave",
last_name: "Wilson",
email: "dave@example.com"
},
actor: actor
)
assert {:error, %Ash.Error.Invalid{}} =
Accounts.update_user(user, %{member: %{id: member2.id}})
Accounts.update_user(user, %{member: %{id: member2.id}}, actor: actor)
end
test "prevents linking user to already linked member on update" do
{:ok, existing_user} = Accounts.create_user(@valid_user_attrs)
{:ok, member} = Membership.create_member(@valid_member_attrs)
test "prevents linking user to already linked member on update", %{actor: actor} do
{:ok, existing_user} = Accounts.create_user(@valid_user_attrs, actor: actor)
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
{:ok, _updated_user} = Accounts.update_user(existing_user, %{member: %{id: member.id}})
{:ok, _updated_user} =
Accounts.update_user(existing_user, %{member: %{id: member.id}}, actor: actor)
{:ok, user2} = Accounts.create_user(%{email: "test5@example.com"})
{:ok, user2} = Accounts.create_user(%{email: "test5@example.com"}, actor: actor)
assert {:error, %Ash.Error.Invalid{}} =
Accounts.update_user(user2, %{member: %{id: member.id}})
Accounts.update_user(user2, %{member: %{id: member.id}}, actor: actor)
end
test "prevents linking member to already linked user on creation" do
{:ok, existing_member} = Membership.create_member(@valid_member_attrs)
test "prevents linking member to already linked user on creation", %{actor: actor} do
{:ok, existing_member} = Membership.create_member(@valid_member_attrs, actor: actor)
user_attrs = Map.put(@valid_user_attrs, :member, %{id: existing_member.id})
{:ok, user} = Accounts.create_user(user_attrs)
{:ok, user} = Accounts.create_user(user_attrs, actor: actor)
assert {:error, %Ash.Error.Invalid{}} =
Membership.create_member(%{
first_name: "Dave",
last_name: "Wilson",
email: "dave@example.com",
user: %{id: user.id}
})
Membership.create_member(
%{
first_name: "Dave",
last_name: "Wilson",
email: "dave@example.com",
user: %{id: user.id}
},
actor: actor
)
end
test "prevents linking user to already linked member on creation" do
{:ok, existing_user} = Accounts.create_user(@valid_user_attrs)
{:ok, member} = Membership.create_member(@valid_member_attrs)
test "prevents linking user to already linked member on creation", %{actor: actor} do
{:ok, existing_user} = Accounts.create_user(@valid_user_attrs, actor: actor)
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
{:ok, _updated_user} = Accounts.update_user(existing_user, %{member: %{id: member.id}})
{:ok, _updated_user} =
Accounts.update_user(existing_user, %{member: %{id: member.id}}, actor: actor)
assert {:error, %Ash.Error.Invalid{}} =
Accounts.create_user(%{
email: "test5@example.com",
member: %{id: member.id}
})
Accounts.create_user(
%{
email: "test5@example.com",
member: %{id: member.id}
},
actor: actor
)
end
end
end

View file

@ -13,23 +13,28 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "assigned_members_count calculation" do
test "returns 0 for custom field without any values" do
test "returns 0 for custom field without any values", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test_field",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
custom_field_with_count = Ash.load!(custom_field, :assigned_members_count)
custom_field_with_count = Ash.load!(custom_field, :assigned_members_count, actor: actor)
assert custom_field_with_count.assigned_members_count == 0
end
test "returns correct count for custom field with one member" do
{:ok, member} = create_member()
{:ok, custom_field} = create_custom_field("test_field", :string)
test "returns correct count for custom field with one member", %{actor: actor} do
{:ok, member} = create_member(actor)
{:ok, custom_field} = create_custom_field("test_field", :string, actor)
{:ok, _custom_field_value} =
CustomFieldValue
@ -38,17 +43,17 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
custom_field_id: custom_field.id,
value: %{"_union_type" => "string", "_union_value" => "test"}
})
|> Ash.create()
|> Ash.create(actor: actor)
custom_field_with_count = Ash.load!(custom_field, :assigned_members_count)
custom_field_with_count = Ash.load!(custom_field, :assigned_members_count, actor: actor)
assert custom_field_with_count.assigned_members_count == 1
end
test "returns correct count for custom field with multiple members" do
{:ok, member1} = create_member()
{:ok, member2} = create_member()
{:ok, member3} = create_member()
{:ok, custom_field} = create_custom_field("test_field", :string)
test "returns correct count for custom field with multiple members", %{actor: actor} do
{:ok, member1} = create_member(actor)
{:ok, member2} = create_member(actor)
{:ok, member3} = create_member(actor)
{:ok, custom_field} = create_custom_field("test_field", :string, actor)
# Create custom field value for each member
for member <- [member1, member2, member3] do
@ -59,16 +64,16 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
custom_field_id: custom_field.id,
value: %{"_union_type" => "string", "_union_value" => "test"}
})
|> Ash.create()
|> Ash.create(actor: actor)
end
custom_field_with_count = Ash.load!(custom_field, :assigned_members_count)
custom_field_with_count = Ash.load!(custom_field, :assigned_members_count, actor: actor)
assert custom_field_with_count.assigned_members_count == 3
end
test "counts distinct members (not multiple values per member)" do
{:ok, member} = create_member()
{:ok, custom_field} = create_custom_field("test_field", :string)
test "counts distinct members (not multiple values per member)", %{actor: actor} do
{:ok, member} = create_member(actor)
{:ok, custom_field} = create_custom_field("test_field", :string, actor)
# Create custom field value for member
{:ok, _} =
@ -78,9 +83,9 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
custom_field_id: custom_field.id,
value: %{"_union_type" => "string", "_union_value" => "test"}
})
|> Ash.create()
|> Ash.create(actor: actor)
custom_field_with_count = Ash.load!(custom_field, :assigned_members_count)
custom_field_with_count = Ash.load!(custom_field, :assigned_members_count, actor: actor)
# Should still be 1, not 2, even if we tried to create multiple (which would fail due to uniqueness)
assert custom_field_with_count.assigned_members_count == 1
@ -88,9 +93,9 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
end
describe "prepare_deletion action" do
test "loads assigned_members_count for deletion preparation" do
{:ok, member} = create_member()
{:ok, custom_field} = create_custom_field("test_field", :string)
test "loads assigned_members_count for deletion preparation", %{actor: actor} do
{:ok, member} = create_member(actor)
{:ok, custom_field} = create_custom_field("test_field", :string, actor)
{:ok, _} =
CustomFieldValue
@ -99,43 +104,43 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
custom_field_id: custom_field.id,
value: %{"_union_type" => "string", "_union_value" => "test"}
})
|> Ash.create()
|> Ash.create(actor: actor)
# Use prepare_deletion action
[prepared_custom_field] =
CustomField
|> Ash.Query.for_read(:prepare_deletion, %{id: custom_field.id})
|> Ash.read!()
|> Ash.read!(actor: actor)
assert prepared_custom_field.assigned_members_count == 1
assert prepared_custom_field.id == custom_field.id
end
test "returns empty list for non-existent custom field" do
test "returns empty list for non-existent custom field", %{actor: actor} do
non_existent_id = Ash.UUID.generate()
result =
CustomField
|> Ash.Query.for_read(:prepare_deletion, %{id: non_existent_id})
|> Ash.read!()
|> Ash.read!(actor: actor)
assert result == []
end
end
describe "destroy_with_values action" do
test "deletes custom field without any values" do
{:ok, custom_field} = create_custom_field("test_field", :string)
test "deletes custom field without any values", %{actor: actor} do
{:ok, custom_field} = create_custom_field("test_field", :string, actor)
assert :ok = Ash.destroy(custom_field)
assert :ok = Ash.destroy(custom_field, actor: actor)
# Verify custom field is deleted
assert {:error, _} = Ash.get(CustomField, custom_field.id)
assert {:error, _} = Ash.get(CustomField, custom_field.id, actor: actor)
end
test "deletes custom field and cascades to all its values" do
{:ok, member} = create_member()
{:ok, custom_field} = create_custom_field("test_field", :string)
test "deletes custom field and cascades to all its values", %{actor: actor} do
{:ok, member} = create_member(actor)
{:ok, custom_field} = create_custom_field("test_field", :string, actor)
{:ok, custom_field_value} =
CustomFieldValue
@ -144,25 +149,25 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
custom_field_id: custom_field.id,
value: %{"_union_type" => "string", "_union_value" => "test"}
})
|> Ash.create()
|> Ash.create(actor: actor)
# Delete custom field
assert :ok = Ash.destroy(custom_field)
assert :ok = Ash.destroy(custom_field, actor: actor)
# Verify custom field is deleted
assert {:error, _} = Ash.get(CustomField, custom_field.id)
assert {:error, _} = Ash.get(CustomField, custom_field.id, actor: actor)
# Verify custom field value is also deleted (CASCADE)
assert {:error, _} = Ash.get(CustomFieldValue, custom_field_value.id)
assert {:error, _} = Ash.get(CustomFieldValue, custom_field_value.id, actor: actor)
# Verify member still exists
assert {:ok, _} = Ash.get(Member, member.id)
assert {:ok, _} = Ash.get(Member, member.id, actor: actor)
end
test "deletes only values of the specific custom field" do
{:ok, member} = create_member()
{:ok, custom_field1} = create_custom_field("field1", :string)
{:ok, custom_field2} = create_custom_field("field2", :string)
test "deletes only values of the specific custom field", %{actor: actor} do
{:ok, member} = create_member(actor)
{:ok, custom_field1} = create_custom_field("field1", :string, actor)
{:ok, custom_field2} = create_custom_field("field2", :string, actor)
# Create value for custom_field1
{:ok, value1} =
@ -172,7 +177,7 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
custom_field_id: custom_field1.id,
value: %{"_union_type" => "string", "_union_value" => "value1"}
})
|> Ash.create()
|> Ash.create(actor: actor)
# Create value for custom_field2
{:ok, value2} =
@ -182,25 +187,25 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
custom_field_id: custom_field2.id,
value: %{"_union_type" => "string", "_union_value" => "value2"}
})
|> Ash.create()
|> Ash.create(actor: actor)
# Delete custom_field1
assert :ok = Ash.destroy(custom_field1)
assert :ok = Ash.destroy(custom_field1, actor: actor)
# Verify custom_field1 and value1 are deleted
assert {:error, _} = Ash.get(CustomField, custom_field1.id)
assert {:error, _} = Ash.get(CustomFieldValue, value1.id)
assert {:error, _} = Ash.get(CustomField, custom_field1.id, actor: actor)
assert {:error, _} = Ash.get(CustomFieldValue, value1.id, actor: actor)
# Verify custom_field2 and value2 still exist
assert {:ok, _} = Ash.get(CustomField, custom_field2.id)
assert {:ok, _} = Ash.get(CustomFieldValue, value2.id)
assert {:ok, _} = Ash.get(CustomField, custom_field2.id, actor: actor)
assert {:ok, _} = Ash.get(CustomFieldValue, value2.id, actor: actor)
end
test "deletes custom field with values from multiple members" do
{:ok, member1} = create_member()
{:ok, member2} = create_member()
{:ok, member3} = create_member()
{:ok, custom_field} = create_custom_field("test_field", :string)
test "deletes custom field with values from multiple members", %{actor: actor} do
{:ok, member1} = create_member(actor)
{:ok, member2} = create_member(actor)
{:ok, member3} = create_member(actor)
{:ok, custom_field} = create_custom_field("test_field", :string, actor)
# Create value for each member
values =
@ -212,43 +217,43 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
custom_field_id: custom_field.id,
value: %{"_union_type" => "string", "_union_value" => "test"}
})
|> Ash.create()
|> Ash.create(actor: actor)
value
end
# Delete custom field
assert :ok = Ash.destroy(custom_field)
assert :ok = Ash.destroy(custom_field, actor: actor)
# Verify all values are deleted
for value <- values do
assert {:error, _} = Ash.get(CustomFieldValue, value.id)
assert {:error, _} = Ash.get(CustomFieldValue, value.id, actor: actor)
end
# Verify all members still exist
for member <- [member1, member2, member3] do
assert {:ok, _} = Ash.get(Member, member.id)
assert {:ok, _} = Ash.get(Member, member.id, actor: actor)
end
end
end
# Helper functions
defp create_member do
defp create_member(actor) do
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User#{System.unique_integer([:positive])}",
email: "test#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create()
|> Ash.create(actor: actor)
end
defp create_custom_field(name, value_type) do
defp create_custom_field(name, value_type, actor) do
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "#{name}_#{System.unique_integer([:positive])}",
value_type: value_type
})
|> Ash.create()
|> Ash.create(actor: actor)
end
end

View file

@ -12,8 +12,13 @@ defmodule Mv.Membership.CustomFieldShowInOverviewTest do
alias Mv.Membership.CustomField
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "show_in_overview attribute" do
test "creates custom field with show_in_overview: true" do
test "creates custom field with show_in_overview: true", %{actor: actor} do
assert {:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
@ -21,24 +26,24 @@ defmodule Mv.Membership.CustomFieldShowInOverviewTest do
value_type: :string,
show_in_overview: true
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.show_in_overview == true
end
test "creates custom field with show_in_overview: true (default)" do
test "creates custom field with show_in_overview: true (default)", %{actor: actor} do
assert {:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test_field_hide",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.show_in_overview == true
end
test "updates show_in_overview to true" do
test "updates show_in_overview to true", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
@ -46,17 +51,17 @@ defmodule Mv.Membership.CustomFieldShowInOverviewTest do
value_type: :string,
show_in_overview: false
})
|> Ash.create()
|> Ash.create(actor: actor)
assert {:ok, updated_field} =
custom_field
|> Ash.Changeset.for_update(:update, %{show_in_overview: true})
|> Ash.update()
|> Ash.update(actor: actor)
assert updated_field.show_in_overview == true
end
test "updates show_in_overview to false" do
test "updates show_in_overview to false", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
@ -64,12 +69,12 @@ defmodule Mv.Membership.CustomFieldShowInOverviewTest do
value_type: :string,
show_in_overview: true
})
|> Ash.create()
|> Ash.create(actor: actor)
assert {:ok, updated_field} =
custom_field
|> Ash.Changeset.for_update(:update, %{show_in_overview: false})
|> Ash.update()
|> Ash.update(actor: actor)
assert updated_field.show_in_overview == false
end

View file

@ -13,94 +13,99 @@ defmodule Mv.Membership.CustomFieldSlugTest do
alias Mv.Membership.CustomField
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "automatic slug generation on create" do
test "generates slug from name with simple ASCII text" do
test "generates slug from name with simple ASCII text", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Mobile Phone",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.slug == "mobile-phone"
end
test "generates slug from name with German umlauts" do
test "generates slug from name with German umlauts", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Café Müller",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.slug == "cafe-muller"
end
test "generates slug with lowercase conversion" do
test "generates slug with lowercase conversion", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "TEST NAME",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.slug == "test-name"
end
test "generates slug by removing special characters" do
test "generates slug by removing special characters", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "E-Mail & Address!",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.slug == "e-mail-address"
end
test "generates slug by replacing multiple spaces with single hyphen" do
test "generates slug by replacing multiple spaces with single hyphen", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Multiple Spaces",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.slug == "multiple-spaces"
end
test "trims leading and trailing hyphens" do
test "trims leading and trailing hyphens", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "-Test-",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.slug == "test"
end
test "handles unicode characters properly (ß becomes ss)" do
test "handles unicode characters properly (ß becomes ss)", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Straße",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.slug == "strasse"
end
end
describe "slug uniqueness" do
test "prevents creating custom field with duplicate slug" do
test "prevents creating custom field with duplicate slug", %{actor: actor} do
# Create first custom field
{:ok, _custom_field} =
CustomField
@ -108,7 +113,7 @@ defmodule Mv.Membership.CustomFieldSlugTest do
name: "Test",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
# Attempt to create second custom field with same slug (different case in name)
assert {:error, %Ash.Error.Invalid{} = error} =
@ -117,19 +122,19 @@ defmodule Mv.Membership.CustomFieldSlugTest do
name: "test",
value_type: :integer
})
|> Ash.create()
|> Ash.create(actor: actor)
assert Exception.message(error) =~ "has already been taken"
end
test "allows custom fields with different slugs" do
test "allows custom fields with different slugs", %{actor: actor} do
{:ok, custom_field1} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Test One",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
{:ok, custom_field2} =
CustomField
@ -137,21 +142,21 @@ defmodule Mv.Membership.CustomFieldSlugTest do
name: "Test Two",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field1.slug == "test-one"
assert custom_field2.slug == "test-two"
assert custom_field1.slug != custom_field2.slug
end
test "prevents duplicate slugs when names differ only in special characters" do
test "prevents duplicate slugs when names differ only in special characters", %{actor: actor} do
{:ok, custom_field1} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Test!!!",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field1.slug == "test"
@ -162,7 +167,7 @@ defmodule Mv.Membership.CustomFieldSlugTest do
name: "Test???",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
# Should fail with uniqueness constraint error
assert Exception.message(error) =~ "has already been taken"
@ -170,7 +175,7 @@ defmodule Mv.Membership.CustomFieldSlugTest do
end
describe "slug immutability" do
test "slug cannot be manually set on create" do
test "slug cannot be manually set on create", %{actor: actor} do
# Attempting to set slug manually should fail because slug is not writable
result =
CustomField
@ -179,14 +184,14 @@ defmodule Mv.Membership.CustomFieldSlugTest do
value_type: :string,
slug: "custom-slug"
})
|> Ash.create()
|> Ash.create(actor: actor)
# Should fail because slug is not an accepted input
assert {:error, %Ash.Error.Invalid{}} = result
assert Exception.message(elem(result, 1)) =~ "No such input"
end
test "slug does not change when name is updated" do
test "slug does not change when name is updated", %{actor: actor} do
# Create custom field
{:ok, custom_field} =
CustomField
@ -194,7 +199,7 @@ defmodule Mv.Membership.CustomFieldSlugTest do
name: "Original Name",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
original_slug = custom_field.slug
assert original_slug == "original-name"
@ -205,7 +210,7 @@ defmodule Mv.Membership.CustomFieldSlugTest do
|> Ash.Changeset.for_update(:update, %{
name: "New Different Name"
})
|> Ash.update()
|> Ash.update(actor: actor)
# Slug should remain unchanged
assert updated_custom_field.slug == original_slug
@ -213,14 +218,14 @@ defmodule Mv.Membership.CustomFieldSlugTest do
assert updated_custom_field.name == "New Different Name"
end
test "slug cannot be manually updated" do
test "slug cannot be manually updated", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Test",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
original_slug = custom_field.slug
assert original_slug == "test"
@ -231,20 +236,20 @@ defmodule Mv.Membership.CustomFieldSlugTest do
|> Ash.Changeset.for_update(:update, %{
slug: "new-slug"
})
|> Ash.update()
|> Ash.update(actor: actor)
# Should fail because slug is not an accepted input
assert {:error, %Ash.Error.Invalid{}} = result
assert Exception.message(elem(result, 1)) =~ "No such input"
# Reload to verify slug hasn't changed
reloaded = Ash.get!(CustomField, custom_field.id)
reloaded = Ash.get!(CustomField, custom_field.id, actor: actor)
assert reloaded.slug == "test"
end
end
describe "slug edge cases" do
test "handles very long names by truncating slug" do
test "handles very long names by truncating slug", %{actor: actor} do
# Create a name at the maximum length (100 chars)
long_name = String.duplicate("abcdefghij", 10)
# 100 characters exactly
@ -255,7 +260,7 @@ defmodule Mv.Membership.CustomFieldSlugTest do
name: long_name,
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
# Slug should be truncated to maximum 100 characters
assert String.length(custom_field.slug) <= 100
@ -263,7 +268,7 @@ defmodule Mv.Membership.CustomFieldSlugTest do
assert custom_field.slug == long_name
end
test "rejects name with only special characters" do
test "rejects name with only special characters", %{actor: actor} do
# When name contains only special characters, slug would be empty
# This should fail validation
assert {:error, %Ash.Error.Invalid{} = error} =
@ -272,59 +277,59 @@ defmodule Mv.Membership.CustomFieldSlugTest do
name: "!!!",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
# Should fail because slug would be empty
error_message = Exception.message(error)
assert error_message =~ "Slug cannot be empty" or error_message =~ "is required"
end
test "handles mixed special characters and text" do
test "handles mixed special characters and text", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Test@#$%Name",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
# slugify keeps the hyphen between words
assert custom_field.slug == "test-name"
end
test "handles numbers in name" do
test "handles numbers in name", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Field 123 Test",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.slug == "field-123-test"
end
test "handles consecutive hyphens in name" do
test "handles consecutive hyphens in name", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Test---Name",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
# Should reduce multiple hyphens to single hyphen
assert custom_field.slug == "test-name"
end
test "handles name with dots and underscores" do
test "handles name with dots and underscores", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test.field_name",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
# Dots and underscores should be handled (either kept or converted)
assert custom_field.slug =~ ~r/^[a-z0-9-]+$/
@ -332,45 +337,45 @@ defmodule Mv.Membership.CustomFieldSlugTest do
end
describe "slug in queries and responses" do
test "slug is included in struct after create" do
test "slug is included in struct after create", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Test",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
# Slug should be present in the struct
assert Map.has_key?(custom_field, :slug)
assert custom_field.slug != nil
end
test "can load custom field and slug is present" do
test "can load custom field and slug is present", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Test",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
# Load it back
loaded_custom_field = Ash.get!(CustomField, custom_field.id)
loaded_custom_field = Ash.get!(CustomField, custom_field.id, actor: actor)
assert loaded_custom_field.slug == "test"
end
test "slug is returned in list queries" do
test "slug is returned in list queries", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Test",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
custom_fields = Ash.read!(CustomField)
custom_fields = Ash.read!(CustomField, actor: actor)
found = Enum.find(custom_fields, &(&1.id == custom_field.id))
assert found.slug == "test"
@ -379,18 +384,18 @@ defmodule Mv.Membership.CustomFieldSlugTest do
describe "slug-based lookup (future feature)" do
@tag :skip
test "can find custom field by slug" do
test "can find custom field by slug", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Test Field",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
# This test is for future implementation
# We might add a custom action like :by_slug
found = Ash.get!(CustomField, custom_field.slug, load: [:slug])
found = Ash.get!(CustomField, custom_field.slug, load: [:slug], actor: actor)
assert found.id == custom_field.id
end
end

View file

@ -13,8 +13,13 @@ defmodule Mv.Membership.CustomFieldValidationTest do
alias Mv.Membership.CustomField
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "name validation" do
test "accepts name with exactly 100 characters" do
test "accepts name with exactly 100 characters", %{actor: actor} do
name = String.duplicate("a", 100)
assert {:ok, custom_field} =
@ -23,13 +28,13 @@ defmodule Mv.Membership.CustomFieldValidationTest do
name: name,
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.name == name
assert String.length(custom_field.name) == 100
end
test "rejects name with 101 characters" do
test "rejects name with 101 characters", %{actor: actor} do
name = String.duplicate("a", 101)
assert {:error, changeset} =
@ -38,50 +43,50 @@ defmodule Mv.Membership.CustomFieldValidationTest do
name: name,
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert [%{field: :name, message: message}] = changeset.errors
assert message =~ "max" or message =~ "length" or message =~ "100"
end
test "trims whitespace from name" do
test "trims whitespace from name", %{actor: actor} do
assert {:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: " test_field ",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.name == "test_field"
end
test "rejects empty name" do
test "rejects empty name", %{actor: actor} do
assert {:error, changeset} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert Enum.any?(changeset.errors, fn error -> error.field == :name end)
end
test "rejects nil name" do
test "rejects nil name", %{actor: actor} do
assert {:error, changeset} =
CustomField
|> Ash.Changeset.for_create(:create, %{
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert Enum.any?(changeset.errors, fn error -> error.field == :name end)
end
end
describe "description validation" do
test "accepts description with exactly 500 characters" do
test "accepts description with exactly 500 characters", %{actor: actor} do
description = String.duplicate("a", 500)
assert {:ok, custom_field} =
@ -91,13 +96,13 @@ defmodule Mv.Membership.CustomFieldValidationTest do
value_type: :string,
description: description
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.description == description
assert String.length(custom_field.description) == 500
end
test "rejects description with 501 characters" do
test "rejects description with 501 characters", %{actor: actor} do
description = String.duplicate("a", 501)
assert {:error, changeset} =
@ -107,13 +112,13 @@ defmodule Mv.Membership.CustomFieldValidationTest do
value_type: :string,
description: description
})
|> Ash.create()
|> Ash.create(actor: actor)
assert [%{field: :description, message: message}] = changeset.errors
assert message =~ "max" or message =~ "length" or message =~ "500"
end
test "trims whitespace from description" do
test "trims whitespace from description", %{actor: actor} do
assert {:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
@ -121,24 +126,24 @@ defmodule Mv.Membership.CustomFieldValidationTest do
value_type: :string,
description: " A nice description "
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.description == "A nice description"
end
test "accepts nil description (optional field)" do
test "accepts nil description (optional field)", %{actor: actor} do
assert {:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test_field",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.description == nil
end
test "accepts empty description after trimming" do
test "accepts empty description after trimming", %{actor: actor} do
assert {:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
@ -146,7 +151,7 @@ defmodule Mv.Membership.CustomFieldValidationTest do
value_type: :string,
description: " "
})
|> Ash.create()
|> Ash.create(actor: actor)
# After trimming whitespace, becomes nil (empty strings are converted to nil)
assert custom_field.description == nil
@ -154,14 +159,14 @@ defmodule Mv.Membership.CustomFieldValidationTest do
end
describe "name uniqueness" do
test "rejects duplicate names" do
test "rejects duplicate names", %{actor: actor} do
assert {:ok, _} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "unique_field",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
assert {:error, changeset} =
CustomField
@ -169,14 +174,14 @@ defmodule Mv.Membership.CustomFieldValidationTest do
name: "unique_field",
value_type: :integer
})
|> Ash.create()
|> Ash.create(actor: actor)
assert Enum.any?(changeset.errors, fn error -> error.field == :name end)
end
end
describe "value_type validation" do
test "accepts all valid value types" do
test "accepts all valid value types", %{actor: actor} do
for value_type <- [:string, :integer, :boolean, :date, :email] do
assert {:ok, custom_field} =
CustomField
@ -184,20 +189,20 @@ defmodule Mv.Membership.CustomFieldValidationTest do
name: "field_#{value_type}",
value_type: value_type
})
|> Ash.create()
|> Ash.create(actor: actor)
assert custom_field.value_type == value_type
end
end
test "rejects invalid value type" do
test "rejects invalid value type", %{actor: actor} do
assert {:error, changeset} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "invalid_field",
value_type: :invalid_type
})
|> Ash.create()
|> Ash.create(actor: actor)
assert [%{field: :value_type}] = changeset.errors
end

View file

@ -13,6 +13,8 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create a test member
{:ok, member} =
Member
@ -21,7 +23,7 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
last_name: "User",
email: "test.validation@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Create custom fields for different types
{:ok, string_field} =
@ -30,7 +32,7 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
name: "string_field",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, integer_field} =
CustomField
@ -38,7 +40,7 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
name: "integer_field",
value_type: :integer
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, email_field} =
CustomField
@ -46,9 +48,10 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
name: "email_field",
value_type: :email
})
|> Ash.create()
|> Ash.create(actor: system_actor)
%{
actor: system_actor,
member: member,
string_field: string_field,
integer_field: integer_field,
@ -58,6 +61,7 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
describe "string value length validation" do
test "accepts string value with exactly 10,000 characters", %{
actor: system_actor,
member: member,
string_field: string_field
} do
@ -73,13 +77,14 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
"_union_value" => value_string
}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert custom_field_value.value.value == value_string
assert String.length(custom_field_value.value.value) == 10_000
end
test "rejects string value with 10,001 characters", %{
actor: system_actor,
member: member,
string_field: string_field
} do
@ -92,14 +97,18 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => value_string}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert Enum.any?(changeset.errors, fn error ->
error.field == :value and (error.message =~ "max" or error.message =~ "length")
end)
end
test "trims whitespace from string value", %{member: member, string_field: string_field} do
test "trims whitespace from string value", %{
actor: system_actor,
member: member,
string_field: string_field
} do
assert {:ok, custom_field_value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
@ -107,12 +116,16 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => " test value "}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert custom_field_value.value.value == "test value"
end
test "accepts empty string value", %{member: member, string_field: string_field} do
test "accepts empty string value", %{
actor: system_actor,
member: member,
string_field: string_field
} do
assert {:ok, custom_field_value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
@ -120,13 +133,17 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => ""}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Empty strings after trimming become nil
assert custom_field_value.value.value == nil
end
test "accepts string with special characters", %{member: member, string_field: string_field} do
test "accepts string with special characters", %{
actor: system_actor,
member: member,
string_field: string_field
} do
special_string = "Hello 世界! 🎉 @#$%^&*()"
assert {:ok, custom_field_value} =
@ -136,14 +153,18 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => special_string}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert custom_field_value.value.value == special_string
end
end
describe "integer value validation" do
test "accepts valid integer value", %{member: member, integer_field: integer_field} do
test "accepts valid integer value", %{
actor: system_actor,
member: member,
integer_field: integer_field
} do
assert {:ok, custom_field_value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
@ -151,12 +172,16 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: integer_field.id,
value: %{"_union_type" => "integer", "_union_value" => 42}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert custom_field_value.value.value == 42
end
test "accepts negative integer", %{member: member, integer_field: integer_field} do
test "accepts negative integer", %{
actor: system_actor,
member: member,
integer_field: integer_field
} do
assert {:ok, custom_field_value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
@ -164,12 +189,12 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: integer_field.id,
value: %{"_union_type" => "integer", "_union_value" => -100}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert custom_field_value.value.value == -100
end
test "accepts zero", %{member: member, integer_field: integer_field} do
test "accepts zero", %{actor: system_actor, member: member, integer_field: integer_field} do
assert {:ok, custom_field_value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
@ -177,14 +202,18 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: integer_field.id,
value: %{"_union_type" => "integer", "_union_value" => 0}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert custom_field_value.value.value == 0
end
end
describe "email value validation" do
test "accepts nil value (optional field)", %{member: member, email_field: email_field} do
test "accepts nil value (optional field)", %{
actor: system_actor,
member: member,
email_field: email_field
} do
assert {:ok, custom_field_value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
@ -192,12 +221,13 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: email_field.id,
value: %{"_union_type" => "email", "_union_value" => nil}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert custom_field_value.value.value == nil
end
test "accepts empty string (becomes nil after trim)", %{
actor: system_actor,
member: member,
email_field: email_field
} do
@ -208,13 +238,13 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: email_field.id,
value: %{"_union_type" => "email", "_union_value" => ""}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Empty string after trim should become nil
assert custom_field_value.value.value == nil
end
test "accepts valid email", %{member: member, email_field: email_field} do
test "accepts valid email", %{actor: system_actor, member: member, email_field: email_field} do
assert {:ok, custom_field_value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
@ -222,12 +252,16 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: email_field.id,
value: %{"_union_type" => "email", "_union_value" => "test@example.com"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert custom_field_value.value.value == "test@example.com"
end
test "rejects invalid email format", %{member: member, email_field: email_field} do
test "rejects invalid email format", %{
actor: system_actor,
member: member,
email_field: email_field
} do
assert {:error, changeset} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
@ -235,12 +269,16 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: email_field.id,
value: %{"_union_type" => "email", "_union_value" => "not-an-email"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert Enum.any?(changeset.errors, fn error -> error.field == :value end)
end
test "rejects email longer than 254 characters", %{member: member, email_field: email_field} do
test "rejects email longer than 254 characters", %{
actor: system_actor,
member: member,
email_field: email_field
} do
# Create an email with >254 chars (243 + 12 = 255)
long_email = String.duplicate("a", 243) <> "@example.com"
@ -251,12 +289,16 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: email_field.id,
value: %{"_union_type" => "email", "_union_value" => long_email}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert Enum.any?(changeset.errors, fn error -> error.field == :value end)
end
test "trims whitespace from email", %{member: member, email_field: email_field} do
test "trims whitespace from email", %{
actor: system_actor,
member: member,
email_field: email_field
} do
assert {:ok, custom_field_value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
@ -264,7 +306,7 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: email_field.id,
value: %{"_union_type" => "email", "_union_value" => " test@example.com "}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert custom_field_value.value.value == "test@example.com"
end
@ -272,6 +314,7 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
describe "uniqueness constraint" do
test "rejects duplicate custom_field_id per member", %{
actor: system_actor,
member: member,
string_field: string_field
} do
@ -283,7 +326,7 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "first value"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Try to create second custom field value with same custom_field_id for same member
assert {:error, changeset} =
@ -293,7 +336,7 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "second value"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Should have uniqueness error
assert Enum.any?(changeset.errors, fn error ->

View file

@ -1,70 +1,93 @@
defmodule Mv.Membership.FuzzySearchTest do
use Mv.DataCase, async: false
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
test "fuzzy_search/2 function exists" do
assert function_exported?(Mv.Membership.Member, :fuzzy_search, 2)
end
test "fuzzy_search returns only John Doe by fuzzy query 'john'" do
test "fuzzy_search returns only John Doe by fuzzy query 'john'", %{actor: actor} do
{:ok, john} =
Mv.Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "john.doe@example.com"
})
Mv.Membership.create_member(
%{
first_name: "John",
last_name: "Doe",
email: "john.doe@example.com"
},
actor: actor
)
{:ok, _jane} =
Mv.Membership.create_member(%{
first_name: "Adriana",
last_name: "Smith",
email: "adriana.smith@example.com"
})
Mv.Membership.create_member(
%{
first_name: "Adriana",
last_name: "Smith",
email: "adriana.smith@example.com"
},
actor: actor
)
{:ok, alice} =
Mv.Membership.create_member(%{
first_name: "Alice",
last_name: "Johnson",
email: "alice.johnson@example.com"
})
Mv.Membership.create_member(
%{
first_name: "Alice",
last_name: "Johnson",
email: "alice.johnson@example.com"
},
actor: actor
)
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{
query: "john"
})
|> Ash.read!()
|> Ash.read!(actor: actor)
assert Enum.map(result, & &1.id) == [john.id, alice.id]
end
test "fuzzy_search finds 'Thomas' when searching misspelled 'tomas'" do
test "fuzzy_search finds 'Thomas' when searching misspelled 'tomas'", %{actor: actor} do
{:ok, thomas} =
Mv.Membership.create_member(%{
first_name: "Thomas",
last_name: "Doe",
email: "john.doe@example.com"
})
Mv.Membership.create_member(
%{
first_name: "Thomas",
last_name: "Doe",
email: "john.doe@example.com"
},
actor: actor
)
{:ok, jane} =
Mv.Membership.create_member(%{
first_name: "Jane",
last_name: "Smith",
email: "jane.smith@example.com"
})
Mv.Membership.create_member(
%{
first_name: "Jane",
last_name: "Smith",
email: "jane.smith@example.com"
},
actor: actor
)
{:ok, _alice} =
Mv.Membership.create_member(%{
first_name: "Alice",
last_name: "Johnson",
email: "alice.johnson@example.com"
})
Mv.Membership.create_member(
%{
first_name: "Alice",
last_name: "Johnson",
email: "alice.johnson@example.com"
},
actor: actor
)
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{
query: "tomas"
})
|> Ash.read!()
|> Ash.read!(actor: actor)
ids = Enum.map(result, & &1.id)
assert thomas.id in ids
@ -72,17 +95,21 @@ defmodule Mv.Membership.FuzzySearchTest do
assert not Enum.empty?(ids)
end
test "empty query returns all members" do
test "empty query returns all members", %{actor: actor} do
{:ok, a} =
Mv.Membership.create_member(%{first_name: "A", last_name: "One", email: "a1@example.com"})
Mv.Membership.create_member(%{first_name: "A", last_name: "One", email: "a1@example.com"},
actor: actor
)
{:ok, b} =
Mv.Membership.create_member(%{first_name: "B", last_name: "Two", email: "b2@example.com"})
Mv.Membership.create_member(%{first_name: "B", last_name: "Two", email: "b2@example.com"},
actor: actor
)
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: ""})
|> Ash.read!()
|> Ash.read!(actor: actor)
assert Enum.sort(Enum.map(result, & &1.id))
|> Enum.uniq()
@ -90,352 +117,435 @@ defmodule Mv.Membership.FuzzySearchTest do
|> Enum.all?(fn id -> id in [a.id, b.id] end)
end
test "substring numeric search matches postal_code mid-string" do
test "substring numeric search matches postal_code mid-string", %{actor: actor} do
{:ok, m1} =
Mv.Membership.create_member(%{
first_name: "Num",
last_name: "One",
email: "n1@example.com",
postal_code: "12345"
})
Mv.Membership.create_member(
%{
first_name: "Num",
last_name: "One",
email: "n1@example.com",
postal_code: "12345"
},
actor: actor
)
{:ok, _m2} =
Mv.Membership.create_member(%{
first_name: "Num",
last_name: "Two",
email: "n2@example.com",
postal_code: "67890"
})
Mv.Membership.create_member(
%{
first_name: "Num",
last_name: "Two",
email: "n2@example.com",
postal_code: "67890"
},
actor: actor
)
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: "345"})
|> Ash.read!()
|> Ash.read!(actor: actor)
ids = Enum.map(result, & &1.id)
assert m1.id in ids
end
test "substring numeric search matches house_number mid-string" do
test "substring numeric search matches house_number mid-string", %{actor: actor} do
{:ok, m1} =
Mv.Membership.create_member(%{
first_name: "Home",
last_name: "One",
email: "h1@example.com",
house_number: "A345B"
})
Mv.Membership.create_member(
%{
first_name: "Home",
last_name: "One",
email: "h1@example.com",
house_number: "A345B"
},
actor: actor
)
{:ok, _m2} =
Mv.Membership.create_member(%{
first_name: "Home",
last_name: "Two",
email: "h2@example.com",
house_number: "77"
})
Mv.Membership.create_member(
%{
first_name: "Home",
last_name: "Two",
email: "h2@example.com",
house_number: "77"
},
actor: actor
)
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: "345"})
|> Ash.read!()
|> Ash.read!(actor: actor)
ids = Enum.map(result, & &1.id)
assert m1.id in ids
end
test "fuzzy matches street misspelling" do
test "fuzzy matches street misspelling", %{actor: actor} do
{:ok, s1} =
Mv.Membership.create_member(%{
first_name: "Road",
last_name: "Test",
email: "s1@example.com",
street: "Main Street"
})
Mv.Membership.create_member(
%{
first_name: "Road",
last_name: "Test",
email: "s1@example.com",
street: "Main Street"
},
actor: actor
)
{:ok, _s2} =
Mv.Membership.create_member(%{
first_name: "Road",
last_name: "Other",
email: "s2@example.com",
street: "Second Avenue"
})
Mv.Membership.create_member(
%{
first_name: "Road",
last_name: "Other",
email: "s2@example.com",
street: "Second Avenue"
},
actor: actor
)
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: "mainn"})
|> Ash.read!()
|> Ash.read!(actor: actor)
ids = Enum.map(result, & &1.id)
assert s1.id in ids
end
test "substring in city matches mid-string" do
test "substring in city matches mid-string", %{actor: actor} do
{:ok, b} =
Mv.Membership.create_member(%{
first_name: "City",
last_name: "One",
email: "city1@example.com",
city: "Berlin"
})
Mv.Membership.create_member(
%{
first_name: "City",
last_name: "One",
email: "city1@example.com",
city: "Berlin"
},
actor: actor
)
{:ok, _m} =
Mv.Membership.create_member(%{
first_name: "City",
last_name: "Two",
email: "city2@example.com",
city: "München"
})
Mv.Membership.create_member(
%{
first_name: "City",
last_name: "Two",
email: "city2@example.com",
city: "München"
},
actor: actor
)
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: "erl"})
|> Ash.read!()
|> Ash.read!(actor: actor)
ids = Enum.map(result, & &1.id)
assert b.id in ids
end
test "blank character handling: query with spaces matches full name" do
test "blank character handling: query with spaces matches full name", %{actor: actor} do
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "john.doe@example.com"
})
Mv.Membership.create_member(
%{
first_name: "John",
last_name: "Doe",
email: "john.doe@example.com"
},
actor: actor
)
{:ok, _other} =
Mv.Membership.create_member(%{
first_name: "Jane",
last_name: "Smith",
email: "jane.smith@example.com"
})
Mv.Membership.create_member(
%{
first_name: "Jane",
last_name: "Smith",
email: "jane.smith@example.com"
},
actor: actor
)
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: "john doe"})
|> Ash.read!()
|> Ash.read!(actor: actor)
ids = Enum.map(result, & &1.id)
assert member.id in ids
end
test "blank character handling: query with multiple spaces is handled" do
test "blank character handling: query with multiple spaces is handled", %{actor: actor} do
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "Mary",
last_name: "Jane",
email: "mary.jane@example.com"
})
Mv.Membership.create_member(
%{
first_name: "Mary",
last_name: "Jane",
email: "mary.jane@example.com"
},
actor: actor
)
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: "mary jane"})
|> Ash.read!()
|> Ash.read!(actor: actor)
ids = Enum.map(result, & &1.id)
assert member.id in ids
end
test "special character handling: @ symbol in query matches email" do
test "special character handling: @ symbol in query matches email", %{actor: actor} do
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "Test",
last_name: "User",
email: "test.user@example.com"
})
Mv.Membership.create_member(
%{
first_name: "Test",
last_name: "User",
email: "test.user@example.com"
},
actor: actor
)
{:ok, _other} =
Mv.Membership.create_member(%{
first_name: "Other",
last_name: "Person",
email: "other.person@different.org"
})
Mv.Membership.create_member(
%{
first_name: "Other",
last_name: "Person",
email: "other.person@different.org"
},
actor: actor
)
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: "example"})
|> Ash.read!()
|> Ash.read!(actor: actor)
ids = Enum.map(result, & &1.id)
assert member.id in ids
end
test "special character handling: dot in query matches email" do
test "special character handling: dot in query matches email", %{actor: actor} do
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "Dot",
last_name: "Test",
email: "dot.test@example.com"
})
Mv.Membership.create_member(
%{
first_name: "Dot",
last_name: "Test",
email: "dot.test@example.com"
},
actor: actor
)
{:ok, _other} =
Mv.Membership.create_member(%{
first_name: "No",
last_name: "Dot",
email: "nodot@example.com"
})
Mv.Membership.create_member(
%{
first_name: "No",
last_name: "Dot",
email: "nodot@example.com"
},
actor: actor
)
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: "dot.test"})
|> Ash.read!()
|> Ash.read!(actor: actor)
ids = Enum.map(result, & &1.id)
assert member.id in ids
end
test "special character handling: hyphen in query matches data" do
test "special character handling: hyphen in query matches data", %{actor: actor} do
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "Mary-Jane",
last_name: "Watson",
email: "mary.jane@example.com"
})
Mv.Membership.create_member(
%{
first_name: "Mary-Jane",
last_name: "Watson",
email: "mary.jane@example.com"
},
actor: actor
)
{:ok, _other} =
Mv.Membership.create_member(%{
first_name: "Mary",
last_name: "Smith",
email: "mary.smith@example.com"
})
Mv.Membership.create_member(
%{
first_name: "Mary",
last_name: "Smith",
email: "mary.smith@example.com"
},
actor: actor
)
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: "mary-jane"})
|> Ash.read!()
|> Ash.read!(actor: actor)
ids = Enum.map(result, & &1.id)
assert member.id in ids
end
test "unicode character handling: umlaut ö in query matches data" do
test "unicode character handling: umlaut ö in query matches data", %{actor: actor} do
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "Jörg",
last_name: "Schmidt",
email: "joerg.schmidt@example.com"
})
Mv.Membership.create_member(
%{
first_name: "Jörg",
last_name: "Schmidt",
email: "joerg.schmidt@example.com"
},
actor: actor
)
{:ok, _other} =
Mv.Membership.create_member(%{
first_name: "John",
last_name: "Smith",
email: "john.smith@example.com"
})
Mv.Membership.create_member(
%{
first_name: "John",
last_name: "Smith",
email: "john.smith@example.com"
},
actor: actor
)
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: "jörg"})
|> Ash.read!()
|> Ash.read!(actor: actor)
ids = Enum.map(result, & &1.id)
assert member.id in ids
end
test "unicode character handling: umlaut ä in query matches data" do
test "unicode character handling: umlaut ä in query matches data", %{actor: actor} do
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "Märta",
last_name: "Andersson",
email: "maerta.andersson@example.com"
})
Mv.Membership.create_member(
%{
first_name: "Märta",
last_name: "Andersson",
email: "maerta.andersson@example.com"
},
actor: actor
)
{:ok, _other} =
Mv.Membership.create_member(%{
first_name: "Marta",
last_name: "Johnson",
email: "marta.johnson@example.com"
})
Mv.Membership.create_member(
%{
first_name: "Marta",
last_name: "Johnson",
email: "marta.johnson@example.com"
},
actor: actor
)
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: "märta"})
|> Ash.read!()
|> Ash.read!(actor: actor)
ids = Enum.map(result, & &1.id)
assert member.id in ids
end
test "unicode character handling: umlaut ü in query matches data" do
test "unicode character handling: umlaut ü in query matches data", %{actor: actor} do
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "Günther",
last_name: "Müller",
email: "guenther.mueller@example.com"
})
Mv.Membership.create_member(
%{
first_name: "Günther",
last_name: "Müller",
email: "guenther.mueller@example.com"
},
actor: actor
)
{:ok, _other} =
Mv.Membership.create_member(%{
first_name: "Gunter",
last_name: "Miller",
email: "gunter.miller@example.com"
})
Mv.Membership.create_member(
%{
first_name: "Gunter",
last_name: "Miller",
email: "gunter.miller@example.com"
},
actor: actor
)
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: "müller"})
|> Ash.read!()
|> Ash.read!(actor: actor)
ids = Enum.map(result, & &1.id)
assert member.id in ids
end
test "unicode character handling: query without umlaut matches data with umlaut" do
test "unicode character handling: query without umlaut matches data with umlaut", %{
actor: actor
} do
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "Müller",
last_name: "Schmidt",
email: "mueller.schmidt@example.com"
})
Mv.Membership.create_member(
%{
first_name: "Müller",
last_name: "Schmidt",
email: "mueller.schmidt@example.com"
},
actor: actor
)
{:ok, _other} =
Mv.Membership.create_member(%{
first_name: "Miller",
last_name: "Smith",
email: "miller.smith@example.com"
})
Mv.Membership.create_member(
%{
first_name: "Miller",
last_name: "Smith",
email: "miller.smith@example.com"
},
actor: actor
)
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: "muller"})
|> Ash.read!()
|> Ash.read!(actor: actor)
ids = Enum.map(result, & &1.id)
assert member.id in ids
end
test "very long search strings: handles long query without error" do
test "very long search strings: handles long query without error", %{actor: actor} do
{:ok, _member} =
Mv.Membership.create_member(%{
first_name: "Test",
last_name: "User",
email: "test@example.com"
})
Mv.Membership.create_member(
%{
first_name: "Test",
last_name: "User",
email: "test@example.com"
},
actor: actor
)
long_query = String.duplicate("a", 1000)
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: long_query})
|> Ash.read!()
|> Ash.read!(actor: actor)
# Should not crash, may return empty or some results
assert is_list(result)
end
test "very long search strings: handles extremely long query" do
test "very long search strings: handles extremely long query", %{actor: actor} do
{:ok, _member} =
Mv.Membership.create_member(%{
first_name: "Test",
last_name: "User",
email: "test@example.com"
})
Mv.Membership.create_member(
%{
first_name: "Test",
last_name: "User",
email: "test@example.com"
},
actor: actor
)
very_long_query = String.duplicate("test query ", 1000)
result =
Mv.Membership.Member
|> Mv.Membership.Member.fuzzy_search(%{query: very_long_query})
|> Ash.read!()
|> Ash.read!(actor: actor)
# Should not crash, may return empty or some results
assert is_list(result)

View file

@ -13,64 +13,87 @@ defmodule Mv.Membership.MemberAvailableForLinkingTest do
describe "available_for_linking/2" do
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create 5 unlinked members with distinct names
{:ok, member1} =
Membership.create_member(%{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com"
})
Membership.create_member(
%{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com"
},
actor: system_actor
)
{:ok, member2} =
Membership.create_member(%{
first_name: "Bob",
last_name: "Williams",
email: "bob@example.com"
})
Membership.create_member(
%{
first_name: "Bob",
last_name: "Williams",
email: "bob@example.com"
},
actor: system_actor
)
{:ok, member3} =
Membership.create_member(%{
first_name: "Charlie",
last_name: "Davis",
email: "charlie@example.com"
})
Membership.create_member(
%{
first_name: "Charlie",
last_name: "Davis",
email: "charlie@example.com"
},
actor: system_actor
)
{:ok, member4} =
Membership.create_member(%{
first_name: "Diana",
last_name: "Martinez",
email: "diana@example.com"
})
Membership.create_member(
%{
first_name: "Diana",
last_name: "Martinez",
email: "diana@example.com"
},
actor: system_actor
)
{:ok, member5} =
Membership.create_member(%{
first_name: "Emma",
last_name: "Taylor",
email: "emma@example.com"
})
Membership.create_member(
%{
first_name: "Emma",
last_name: "Taylor",
email: "emma@example.com"
},
actor: system_actor
)
unlinked_members = [member1, member2, member3, member4, member5]
# Create 2 linked members (with users)
{:ok, user1} = Mv.Accounts.create_user(%{email: "user1@example.com"})
{:ok, user1} = Mv.Accounts.create_user(%{email: "user1@example.com"}, actor: system_actor)
{:ok, linked_member1} =
Membership.create_member(%{
first_name: "Linked",
last_name: "Member1",
email: "linked1@example.com",
user: %{id: user1.id}
})
Membership.create_member(
%{
first_name: "Linked",
last_name: "Member1",
email: "linked1@example.com",
user: %{id: user1.id}
},
actor: system_actor
)
{:ok, user2} = Mv.Accounts.create_user(%{email: "user2@example.com"})
{:ok, user2} = Mv.Accounts.create_user(%{email: "user2@example.com"}, actor: system_actor)
{:ok, linked_member2} =
Membership.create_member(%{
first_name: "Linked",
last_name: "Member2",
email: "linked2@example.com",
user: %{id: user2.id}
})
Membership.create_member(
%{
first_name: "Linked",
last_name: "Member2",
email: "linked2@example.com",
user: %{id: user2.id}
},
actor: system_actor
)
%{
unlinked_members: unlinked_members,
@ -82,11 +105,13 @@ defmodule Mv.Membership.MemberAvailableForLinkingTest do
unlinked_members: unlinked_members,
linked_members: _linked_members
} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Call the action without any arguments
members =
Mv.Membership.Member
|> Ash.Query.for_read(:available_for_linking, %{})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
# Should return only the 5 unlinked members, not the 2 linked ones
assert length(members) == 5
@ -98,25 +123,32 @@ defmodule Mv.Membership.MemberAvailableForLinkingTest do
# Verify none of the returned members have a user_id
Enum.each(members, fn member ->
member_with_user = Ash.get!(Mv.Membership.Member, member.id, load: [:user])
member_with_user =
Ash.get!(Mv.Membership.Member, member.id, actor: system_actor, load: [:user])
assert is_nil(member_with_user.user)
end)
end
test "limits results to 10 members even when more exist" do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create 15 additional unlinked members (total 20 unlinked)
for i <- 6..20 do
Membership.create_member(%{
first_name: "Extra#{i}",
last_name: "Member#{i}",
email: "extra#{i}@example.com"
})
Membership.create_member(
%{
first_name: "Extra#{i}",
last_name: "Member#{i}",
email: "extra#{i}@example.com"
},
actor: system_actor
)
end
members =
Mv.Membership.Member
|> Ash.Query.for_read(:available_for_linking, %{})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
# Should be limited to 10
assert length(members) == 10
@ -125,6 +157,8 @@ defmodule Mv.Membership.MemberAvailableForLinkingTest do
test "email match: returns only member with matching email when exists", %{
unlinked_members: unlinked_members
} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Get one of the unlinked members' email
target_member = List.first(unlinked_members)
user_email = target_member.email
@ -132,7 +166,7 @@ defmodule Mv.Membership.MemberAvailableForLinkingTest do
raw_members =
Mv.Membership.Member
|> Ash.Query.for_read(:available_for_linking, %{user_email: user_email})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
# Apply email match filtering (sorted results come from query)
# When user_email matches, only that member should be returned
@ -145,13 +179,15 @@ defmodule Mv.Membership.MemberAvailableForLinkingTest do
end
test "email match: returns all unlinked members when no email match" do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Use an email that doesn't match any member
non_matching_email = "nonexistent@example.com"
raw_members =
Mv.Membership.Member
|> Ash.Query.for_read(:available_for_linking, %{user_email: non_matching_email})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
# Apply email match filtering
members = Mv.Membership.Member.filter_by_email_match(raw_members, non_matching_email)
@ -163,11 +199,13 @@ defmodule Mv.Membership.MemberAvailableForLinkingTest do
test "search query: filters by first_name, last_name, and email", %{
unlinked_members: _unlinked_members
} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Search by first name
members =
Mv.Membership.Member
|> Ash.Query.for_read(:available_for_linking, %{search_query: "Alice"})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
assert length(members) == 1
assert List.first(members).first_name == "Alice"
@ -176,7 +214,7 @@ defmodule Mv.Membership.MemberAvailableForLinkingTest do
members =
Mv.Membership.Member
|> Ash.Query.for_read(:available_for_linking, %{search_query: "Williams"})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
assert length(members) == 1
assert List.first(members).last_name == "Williams"
@ -185,7 +223,7 @@ defmodule Mv.Membership.MemberAvailableForLinkingTest do
members =
Mv.Membership.Member
|> Ash.Query.for_read(:available_for_linking, %{search_query: "charlie@"})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
assert length(members) == 1
assert List.first(members).email == "charlie@example.com"
@ -194,12 +232,13 @@ defmodule Mv.Membership.MemberAvailableForLinkingTest do
members =
Mv.Membership.Member
|> Ash.Query.for_read(:available_for_linking, %{search_query: "NonExistent"})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
assert Enum.empty?(members)
end
test "user_email takes precedence over search_query", %{unlinked_members: unlinked_members} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
target_member = List.first(unlinked_members)
# Pass both email match and search query that would match different members
@ -209,7 +248,7 @@ defmodule Mv.Membership.MemberAvailableForLinkingTest do
user_email: target_member.email,
search_query: "Bob"
})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
# Apply email-match filter (as LiveView does)
members = Mv.Membership.Member.filter_by_email_match(raw_members, target_member.email)

View file

@ -9,8 +9,13 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.CalendarCycles
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
# Helper to create a membership fee type
defp create_fee_type(attrs) do
defp create_fee_type(attrs, actor) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
@ -21,11 +26,11 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
|> Ash.create!(actor: actor)
end
# Helper to create a member
defp create_member(attrs) do
defp create_member(attrs, actor) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
@ -36,11 +41,11 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
|> Ash.create!(actor: actor)
end
# Helper to create a cycle
defp create_cycle(member, fee_type, attrs) do
defp create_cycle(member, fee_type, attrs, actor) do
default_attrs = %{
cycle_start: ~D[2024-01-01],
amount: Decimal.new("50.00"),
@ -53,62 +58,77 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
|> Ash.create!(actor: actor)
end
describe "current_cycle_status" do
test "returns status of current cycle for member with active cycle" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
test "returns status of current cycle for member with active cycle", %{actor: actor} do
fee_type = create_fee_type(%{interval: :yearly}, actor)
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
# Create a cycle that is active today (2024-01-01 to 2024-12-31)
# Assuming today is in 2024
today = Date.utc_today()
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
create_cycle(member, fee_type, %{
cycle_start: cycle_start,
status: :paid
})
create_cycle(
member,
fee_type,
%{
cycle_start: cycle_start,
status: :paid
},
actor
)
member = Ash.load!(member, :current_cycle_status)
assert member.current_cycle_status == :paid
end
test "returns nil for member without current cycle" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
test "returns nil for member without current cycle", %{actor: actor} do
fee_type = create_fee_type(%{interval: :yearly}, actor)
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
# Create a cycle in the past (not current)
create_cycle(member, fee_type, %{
cycle_start: ~D[2020-01-01],
status: :paid
})
create_cycle(
member,
fee_type,
%{
cycle_start: ~D[2020-01-01],
status: :paid
},
actor
)
member = Ash.load!(member, :current_cycle_status)
assert member.current_cycle_status == nil
end
test "returns nil for member without cycles" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
test "returns nil for member without cycles", %{actor: actor} do
fee_type = create_fee_type(%{interval: :yearly}, actor)
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
member = Ash.load!(member, :current_cycle_status)
assert member.current_cycle_status == nil
end
test "returns status of current cycle for monthly interval" do
fee_type = create_fee_type(%{interval: :monthly})
member = create_member(%{membership_fee_type_id: fee_type.id})
test "returns status of current cycle for monthly interval", %{actor: actor} do
fee_type = create_fee_type(%{interval: :monthly}, actor)
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
# Create a cycle that is active today (current month)
today = Date.utc_today()
cycle_start = CalendarCycles.calculate_cycle_start(today, :monthly)
create_cycle(member, fee_type, %{
cycle_start: cycle_start,
status: :unpaid
})
create_cycle(
member,
fee_type,
%{
cycle_start: cycle_start,
status: :unpaid
},
actor
)
member = Ash.load!(member, :current_cycle_status)
assert member.current_cycle_status == :unpaid
@ -116,79 +136,109 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
end
describe "last_cycle_status" do
test "returns status of last completed cycle" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
test "returns status of last completed cycle", %{actor: actor} do
fee_type = create_fee_type(%{interval: :yearly}, actor)
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
# Create cycles: 2022 (completed), 2023 (completed), 2024 (current)
today = Date.utc_today()
create_cycle(member, fee_type, %{
cycle_start: ~D[2022-01-01],
status: :paid
})
create_cycle(
member,
fee_type,
%{
cycle_start: ~D[2022-01-01],
status: :paid
},
actor
)
create_cycle(member, fee_type, %{
cycle_start: ~D[2023-01-01],
status: :unpaid
})
create_cycle(
member,
fee_type,
%{
cycle_start: ~D[2023-01-01],
status: :unpaid
},
actor
)
# Current cycle
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
create_cycle(member, fee_type, %{
cycle_start: cycle_start,
status: :paid
})
create_cycle(
member,
fee_type,
%{
cycle_start: cycle_start,
status: :paid
},
actor
)
member = Ash.load!(member, :last_cycle_status)
# Should return status of 2023 (last completed)
assert member.last_cycle_status == :unpaid
end
test "returns nil for member without completed cycles" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
test "returns nil for member without completed cycles", %{actor: actor} do
fee_type = create_fee_type(%{interval: :yearly}, actor)
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
# Only create current cycle (not completed yet)
today = Date.utc_today()
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
create_cycle(member, fee_type, %{
cycle_start: cycle_start,
status: :paid
})
create_cycle(
member,
fee_type,
%{
cycle_start: cycle_start,
status: :paid
},
actor
)
member = Ash.load!(member, :last_cycle_status)
assert member.last_cycle_status == nil
end
test "returns nil for member without cycles" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
test "returns nil for member without cycles", %{actor: actor} do
fee_type = create_fee_type(%{interval: :yearly}, actor)
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
member = Ash.load!(member, :last_cycle_status)
assert member.last_cycle_status == nil
end
test "returns status of last completed cycle for monthly interval" do
fee_type = create_fee_type(%{interval: :monthly})
member = create_member(%{membership_fee_type_id: fee_type.id})
test "returns status of last completed cycle for monthly interval", %{actor: actor} do
fee_type = create_fee_type(%{interval: :monthly}, actor)
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
today = Date.utc_today()
# Create cycles: last month (completed), current month (not completed)
last_month_start = Date.add(today, -32) |> CalendarCycles.calculate_cycle_start(:monthly)
current_month_start = CalendarCycles.calculate_cycle_start(today, :monthly)
create_cycle(member, fee_type, %{
cycle_start: last_month_start,
status: :paid
})
create_cycle(
member,
fee_type,
%{
cycle_start: last_month_start,
status: :paid
},
actor
)
create_cycle(member, fee_type, %{
cycle_start: current_month_start,
status: :unpaid
})
create_cycle(
member,
fee_type,
%{
cycle_start: current_month_start,
status: :unpaid
},
actor
)
member = Ash.load!(member, :last_cycle_status)
# Should return status of last month (last completed)
@ -197,9 +247,9 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
end
describe "overdue_count" do
test "counts only unpaid cycles that have ended" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
test "counts only unpaid cycles that have ended", %{actor: actor} do
fee_type = create_fee_type(%{interval: :yearly}, actor)
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
today = Date.utc_today()
@ -209,23 +259,38 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
# 2024: unpaid, current (not overdue)
# 2025: unpaid, future (not overdue)
create_cycle(member, fee_type, %{
cycle_start: ~D[2022-01-01],
status: :unpaid
})
create_cycle(
member,
fee_type,
%{
cycle_start: ~D[2022-01-01],
status: :unpaid
},
actor
)
create_cycle(member, fee_type, %{
cycle_start: ~D[2023-01-01],
status: :paid
})
create_cycle(
member,
fee_type,
%{
cycle_start: ~D[2023-01-01],
status: :paid
},
actor
)
# Current cycle
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
create_cycle(member, fee_type, %{
cycle_start: cycle_start,
status: :unpaid
})
create_cycle(
member,
fee_type,
%{
cycle_start: cycle_start,
status: :unpaid
},
actor
)
# Future cycle (if we're not at the end of the year)
next_year = today.year + 1
@ -233,10 +298,15 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
if today.month < 12 or today.day < 31 do
next_year_start = Date.new!(next_year, 1, 1)
create_cycle(member, fee_type, %{
cycle_start: next_year_start,
status: :unpaid
})
create_cycle(
member,
fee_type,
%{
cycle_start: next_year_start,
status: :unpaid
},
actor
)
end
member = Ash.load!(member, :overdue_count)
@ -244,31 +314,36 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
assert member.overdue_count == 1
end
test "returns 0 when no overdue cycles" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
test "returns 0 when no overdue cycles", %{actor: actor} do
fee_type = create_fee_type(%{interval: :yearly}, actor)
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
# Create only paid cycles
create_cycle(member, fee_type, %{
cycle_start: ~D[2022-01-01],
status: :paid
})
create_cycle(
member,
fee_type,
%{
cycle_start: ~D[2022-01-01],
status: :paid
},
actor
)
member = Ash.load!(member, :overdue_count)
assert member.overdue_count == 0
end
test "returns 0 for member without cycles" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
test "returns 0 for member without cycles", %{actor: actor} do
fee_type = create_fee_type(%{interval: :yearly}, actor)
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
member = Ash.load!(member, :overdue_count)
assert member.overdue_count == 0
end
test "counts overdue cycles for monthly interval" do
fee_type = create_fee_type(%{interval: :monthly})
member = create_member(%{membership_fee_type_id: fee_type.id})
test "counts overdue cycles for monthly interval", %{actor: actor} do
fee_type = create_fee_type(%{interval: :monthly}, actor)
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
today = Date.utc_today()
@ -279,45 +354,75 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
last_month_start = Date.add(today, -32) |> CalendarCycles.calculate_cycle_start(:monthly)
current_month_start = CalendarCycles.calculate_cycle_start(today, :monthly)
create_cycle(member, fee_type, %{
cycle_start: two_months_ago_start,
status: :unpaid
})
create_cycle(
member,
fee_type,
%{
cycle_start: two_months_ago_start,
status: :unpaid
},
actor
)
create_cycle(member, fee_type, %{
cycle_start: last_month_start,
status: :paid
})
create_cycle(
member,
fee_type,
%{
cycle_start: last_month_start,
status: :paid
},
actor
)
create_cycle(member, fee_type, %{
cycle_start: current_month_start,
status: :unpaid
})
create_cycle(
member,
fee_type,
%{
cycle_start: current_month_start,
status: :unpaid
},
actor
)
member = Ash.load!(member, :overdue_count)
# Should only count two_months_ago (unpaid and ended)
assert member.overdue_count == 1
end
test "counts multiple overdue cycles" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
test "counts multiple overdue cycles", %{actor: actor} do
fee_type = create_fee_type(%{interval: :yearly}, actor)
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
# Create multiple unpaid, ended cycles
create_cycle(member, fee_type, %{
cycle_start: ~D[2020-01-01],
status: :unpaid
})
create_cycle(
member,
fee_type,
%{
cycle_start: ~D[2020-01-01],
status: :unpaid
},
actor
)
create_cycle(member, fee_type, %{
cycle_start: ~D[2021-01-01],
status: :unpaid
})
create_cycle(
member,
fee_type,
%{
cycle_start: ~D[2021-01-01],
status: :unpaid
},
actor
)
create_cycle(member, fee_type, %{
cycle_start: ~D[2022-01-01],
status: :unpaid
})
create_cycle(
member,
fee_type,
%{
cycle_start: ~D[2022-01-01],
status: :unpaid
},
actor
)
member = Ash.load!(member, :overdue_count)
assert member.overdue_count == 3
@ -325,29 +430,44 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
end
describe "calculations with multiple cycles" do
test "all calculations work correctly with multiple cycles" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
test "all calculations work correctly with multiple cycles", %{actor: actor} do
fee_type = create_fee_type(%{interval: :yearly}, actor)
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
today = Date.utc_today()
# Create cycles: 2022 (unpaid, ended), 2023 (paid, ended), 2024 (unpaid, current)
create_cycle(member, fee_type, %{
cycle_start: ~D[2022-01-01],
status: :unpaid
})
create_cycle(
member,
fee_type,
%{
cycle_start: ~D[2022-01-01],
status: :unpaid
},
actor
)
create_cycle(member, fee_type, %{
cycle_start: ~D[2023-01-01],
status: :paid
})
create_cycle(
member,
fee_type,
%{
cycle_start: ~D[2023-01-01],
status: :paid
},
actor
)
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
create_cycle(member, fee_type, %{
cycle_start: cycle_start,
status: :unpaid
})
create_cycle(
member,
fee_type,
%{
cycle_start: cycle_start,
status: :unpaid
},
actor
)
member =
Ash.load!(member, [:current_cycle_status, :last_cycle_status, :overdue_count])

View file

@ -8,6 +8,11 @@ defmodule Mv.Membership.MemberEmailSyncTest do
alias Mv.Accounts
alias Mv.Membership
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "Member email synchronization to linked User" do
@valid_user_attrs %{
email: "user@example.com"
@ -19,108 +24,119 @@ defmodule Mv.Membership.MemberEmailSyncTest do
email: "member@example.com"
}
test "updating member email syncs to linked user" do
test "updating member email syncs to linked user", %{actor: actor} do
# Create a user
{:ok, user} = Accounts.create_user(@valid_user_attrs)
{:ok, user} = Accounts.create_user(@valid_user_attrs, actor: actor)
assert to_string(user.email) == "user@example.com"
# Create a member linked to the user
{:ok, member} =
Membership.create_member(Map.put(@valid_member_attrs, :user, %{id: user.id}))
Membership.create_member(Map.put(@valid_member_attrs, :user, %{id: user.id}),
actor: actor
)
# Verify initial state - member email should be overridden by user email
{:ok, member_after_create} = Ash.get(Mv.Membership.Member, member.id)
{:ok, member_after_create} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
assert member_after_create.email == "user@example.com"
# Update member email
{:ok, updated_member} =
Membership.update_member(member, %{email: "newmember@example.com"})
Membership.update_member(member, %{email: "newmember@example.com"}, actor: actor)
assert updated_member.email == "newmember@example.com"
# Verify user email was also updated
{:ok, synced_user} = Ash.get(Mv.Accounts.User, user.id)
{:ok, synced_user} = Ash.get(Mv.Accounts.User, user.id, actor: actor)
assert to_string(synced_user.email) == "newmember@example.com"
end
test "creating member linked to user syncs user email to member" do
test "creating member linked to user syncs user email to member", %{actor: actor} do
# Create a user with their own email
{:ok, user} = Accounts.create_user(@valid_user_attrs)
{:ok, user} = Accounts.create_user(@valid_user_attrs, actor: actor)
assert to_string(user.email) == "user@example.com"
# Create a member linked to this user
{:ok, member} =
Membership.create_member(Map.put(@valid_member_attrs, :user, %{id: user.id}))
Membership.create_member(Map.put(@valid_member_attrs, :user, %{id: user.id}),
actor: actor
)
# Member should have been created with user's email (user is source of truth)
assert member.email == "user@example.com"
# Verify the link
{:ok, loaded_member} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
{:ok, loaded_member} = Ash.get(Mv.Membership.Member, member.id, load: [:user], actor: actor)
assert loaded_member.user.id == user.id
end
test "linking member to existing user syncs user email to member" do
test "linking member to existing user syncs user email to member", %{actor: actor} do
# Create a standalone user
{:ok, user} = Accounts.create_user(@valid_user_attrs)
{:ok, user} = Accounts.create_user(@valid_user_attrs, actor: actor)
assert to_string(user.email) == "user@example.com"
# Create a standalone member
{:ok, member} = Membership.create_member(@valid_member_attrs)
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
assert member.email == "member@example.com"
# Link the member to the user
{:ok, linked_member} = Membership.update_member(member, %{user: %{id: user.id}})
{:ok, linked_member} =
Membership.update_member(member, %{user: %{id: user.id}}, actor: actor)
# Verify the link
{:ok, loaded_member} = Ash.get(Mv.Membership.Member, linked_member.id, load: [:user])
{:ok, loaded_member} =
Ash.get(Mv.Membership.Member, linked_member.id, load: [:user], actor: actor)
assert loaded_member.user.id == user.id
# Verify member email was overridden with user email
assert loaded_member.email == "user@example.com"
end
test "updating member email when no user linked does not error" do
test "updating member email when no user linked does not error", %{actor: actor} do
# Create a standalone member without user link
{:ok, member} = Membership.create_member(@valid_member_attrs)
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
assert member.email == "member@example.com"
# Load to verify no user link
{:ok, loaded_member} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
{:ok, loaded_member} = Ash.get(Mv.Membership.Member, member.id, load: [:user], actor: actor)
assert loaded_member.user == nil
# Update member email - should work fine without error
{:ok, updated_member} =
Membership.update_member(member, %{email: "newemail@example.com"})
Membership.update_member(member, %{email: "newemail@example.com"}, actor: actor)
assert updated_member.email == "newemail@example.com"
end
test "unlinking member from user does not sync email" do
test "unlinking member from user does not sync email", %{actor: actor} do
# Create user
{:ok, user} = Accounts.create_user(@valid_user_attrs)
{:ok, user} = Accounts.create_user(@valid_user_attrs, actor: actor)
# Create member linked to user
{:ok, member} =
Membership.create_member(Map.put(@valid_member_attrs, :user, %{id: user.id}))
Membership.create_member(Map.put(@valid_member_attrs, :user, %{id: user.id}),
actor: actor
)
# Verify member email was synced to user email
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
assert synced_member.email == "user@example.com"
# Verify link exists
{:ok, loaded_member} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
{:ok, loaded_member} = Ash.get(Mv.Membership.Member, member.id, load: [:user], actor: actor)
assert loaded_member.user != nil
# Unlink member from user
{:ok, unlinked_member} = Membership.update_member(member, %{user: nil})
{:ok, unlinked_member} = Membership.update_member(member, %{user: nil}, actor: actor)
# Verify unlink
{:ok, loaded_unlinked} = Ash.get(Mv.Membership.Member, unlinked_member.id, load: [:user])
{:ok, loaded_unlinked} =
Ash.get(Mv.Membership.Member, unlinked_member.id, load: [:user], actor: actor)
assert loaded_unlinked.user == nil
# User email should remain unchanged after unlinking
{:ok, user_after_unlink} = Ash.get(Mv.Accounts.User, user.id)
{:ok, user_after_unlink} = Ash.get(Mv.Accounts.User, user.id, actor: actor)
assert to_string(user_after_unlink.email) == "user@example.com"
end
end

View file

@ -9,15 +9,23 @@ defmodule Mv.Membership.MemberFuzzySearchLinkingTest do
alias Mv.Accounts
alias Mv.Membership
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "available_for_linking with fuzzy search" do
test "finds member despite typo" do
test "finds member despite typo", %{actor: actor} do
# Create member with specific name
{:ok, member} =
Membership.create_member(%{
first_name: "Jonathan",
last_name: "Smith",
email: "jonathan@example.com"
})
Membership.create_member(
%{
first_name: "Jonathan",
last_name: "Smith",
email: "jonathan@example.com"
},
actor: actor
)
# Search with typo
query =
@ -27,21 +35,24 @@ defmodule Mv.Membership.MemberFuzzySearchLinkingTest do
search_query: "Jonatan"
})
{:ok, members} = Ash.read(query, domain: Mv.Membership)
{:ok, members} = Ash.read(query, domain: Mv.Membership, actor: actor)
# Should find Jonathan despite typo
assert length(members) == 1
assert hd(members).id == member.id
end
test "finds member with partial match" do
test "finds member with partial match", %{actor: actor} do
# Create member
{:ok, member} =
Membership.create_member(%{
first_name: "Alexander",
last_name: "Williams",
email: "alex@example.com"
})
Membership.create_member(
%{
first_name: "Alexander",
last_name: "Williams",
email: "alex@example.com"
},
actor: actor
)
# Search with partial
query =
@ -51,28 +62,34 @@ defmodule Mv.Membership.MemberFuzzySearchLinkingTest do
search_query: "Alex"
})
{:ok, members} = Ash.read(query, domain: Mv.Membership)
{:ok, members} = Ash.read(query, domain: Mv.Membership, actor: actor)
# Should find Alexander
assert length(members) == 1
assert hd(members).id == member.id
end
test "email match overrides fuzzy search" do
test "email match overrides fuzzy search", %{actor: actor} do
# Create two members
{:ok, member1} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "john@example.com"
})
Membership.create_member(
%{
first_name: "John",
last_name: "Doe",
email: "john@example.com"
},
actor: actor
)
{:ok, _member2} =
Membership.create_member(%{
first_name: "Jane",
last_name: "Smith",
email: "jane@example.com"
})
Membership.create_member(
%{
first_name: "Jane",
last_name: "Smith",
email: "jane@example.com"
},
actor: actor
)
# Search with user_email that matches member1, but search_query that would match member2
query =
@ -82,7 +99,7 @@ defmodule Mv.Membership.MemberFuzzySearchLinkingTest do
search_query: "Jane"
})
{:ok, members} = Ash.read(query, domain: Mv.Membership)
{:ok, members} = Ash.read(query, domain: Mv.Membership, actor: actor)
# Apply email filter
filtered_members = Mv.Membership.Member.filter_by_email_match(members, "john@example.com")
@ -92,14 +109,17 @@ defmodule Mv.Membership.MemberFuzzySearchLinkingTest do
assert hd(filtered_members).id == member1.id
end
test "limits to 10 results" do
test "limits to 10 results", %{actor: actor} do
# Create 15 members with similar names
for i <- 1..15 do
Membership.create_member(%{
first_name: "Test#{i}",
last_name: "Member",
email: "test#{i}@example.com"
})
Membership.create_member(
%{
first_name: "Test#{i}",
last_name: "Member",
email: "test#{i}@example.com"
},
actor: actor
)
end
# Search for "Test"
@ -110,34 +130,43 @@ defmodule Mv.Membership.MemberFuzzySearchLinkingTest do
search_query: "Test"
})
{:ok, members} = Ash.read(query, domain: Mv.Membership)
{:ok, members} = Ash.read(query, domain: Mv.Membership, actor: actor)
# Should return max 10 members
assert length(members) == 10
end
test "excludes linked members" do
test "excludes linked members", %{actor: actor} do
# Create member and link to user
{:ok, member1} =
Membership.create_member(%{
first_name: "Linked",
last_name: "Member",
email: "linked@example.com"
})
Membership.create_member(
%{
first_name: "Linked",
last_name: "Member",
email: "linked@example.com"
},
actor: actor
)
{:ok, _user} =
Accounts.create_user(%{
email: "user@example.com",
member: %{id: member1.id}
})
Accounts.create_user(
%{
email: "user@example.com",
member: %{id: member1.id}
},
actor: actor
)
# Create unlinked member
{:ok, member2} =
Membership.create_member(%{
first_name: "Unlinked",
last_name: "Member",
email: "unlinked@example.com"
})
Membership.create_member(
%{
first_name: "Unlinked",
last_name: "Member",
email: "unlinked@example.com"
},
actor: actor
)
# Search for "Member"
query =
@ -147,7 +176,7 @@ defmodule Mv.Membership.MemberFuzzySearchLinkingTest do
search_query: "Member"
})
{:ok, members} = Ash.read(query, domain: Mv.Membership)
{:ok, members} = Ash.read(query, domain: Mv.Membership, actor: actor)
# Should only return unlinked member
member_ids = Enum.map(members, & &1.id)

View file

@ -14,6 +14,8 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
alias Mv.Membership
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create required custom fields for different types
{:ok, required_string_field} =
Membership.CustomField
@ -22,7 +24,7 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
value_type: :string,
required: true
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, required_integer_field} =
Membership.CustomField
@ -31,7 +33,7 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
value_type: :integer,
required: true
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, required_boolean_field} =
Membership.CustomField
@ -40,7 +42,7 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
value_type: :boolean,
required: true
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, required_date_field} =
Membership.CustomField
@ -49,7 +51,7 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
value_type: :date,
required: true
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, required_email_field} =
Membership.CustomField
@ -58,7 +60,7 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
value_type: :email,
required: true
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, optional_field} =
Membership.CustomField
@ -67,7 +69,7 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
value_type: :string,
required: false
})
|> Ash.create()
|> Ash.create(actor: system_actor)
%{
required_string_field: required_string_field,
@ -75,7 +77,8 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
required_boolean_field: required_boolean_field,
required_date_field: required_date_field,
required_email_field: required_email_field,
optional_field: optional_field
optional_field: optional_field,
actor: system_actor
}
end
@ -118,17 +121,23 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
email: "john@example.com"
}
test "fails when required custom field is missing", %{required_string_field: field} do
test "fails when required custom field is missing", %{
required_string_field: field,
actor: actor
} do
attrs = Map.put(@valid_attrs, :custom_field_values, [])
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_member(attrs, actor: actor)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "fails when required string custom field has nil value",
%{
required_string_field: field
required_string_field: field,
actor: actor
} = context do
# Start with all required fields having valid values
custom_field_values =
@ -143,14 +152,17 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_member(attrs, actor: actor)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "fails when required string custom field has empty string value",
%{
required_string_field: field
required_string_field: field,
actor: actor
} = context do
# Start with all required fields having valid values
custom_field_values =
@ -165,14 +177,17 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_member(attrs, actor: actor)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "fails when required string custom field has whitespace-only value",
%{
required_string_field: field
required_string_field: field,
actor: actor
} = context do
# Start with all required fields having valid values
custom_field_values =
@ -187,14 +202,17 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_member(attrs, actor: actor)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "succeeds when required string custom field has valid value",
%{
required_string_field: field
required_string_field: field,
actor: actor
} = context do
# Start with all required fields having valid values, then update the string field
custom_field_values =
@ -209,12 +227,13 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
assert {:ok, _member} = Membership.create_member(attrs, actor: actor)
end
test "fails when required integer custom field has nil value",
%{
required_integer_field: field
required_integer_field: field,
actor: actor
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
@ -228,14 +247,17 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_member(attrs, actor: actor)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "fails when required integer custom field has empty string value",
%{
required_integer_field: field
required_integer_field: field,
actor: actor
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
@ -249,25 +271,29 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_member(attrs, actor: actor)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "succeeds when required integer custom field has zero value",
%{
required_integer_field: _field
required_integer_field: _field,
actor: actor
} = context do
custom_field_values = all_required_custom_fields_with_defaults(context)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
assert {:ok, _member} = Membership.create_member(attrs, actor: actor)
end
test "succeeds when required integer custom field has positive value",
%{
required_integer_field: field
required_integer_field: field,
actor: actor
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
@ -281,12 +307,13 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
assert {:ok, _member} = Membership.create_member(attrs, actor: actor)
end
test "fails when required boolean custom field has nil value",
%{
required_boolean_field: field
required_boolean_field: field,
actor: actor
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
@ -300,25 +327,29 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_member(attrs, actor: actor)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "succeeds when required boolean custom field has false value",
%{
required_boolean_field: _field
required_boolean_field: _field,
actor: actor
} = context do
custom_field_values = all_required_custom_fields_with_defaults(context)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
assert {:ok, _member} = Membership.create_member(attrs, actor: actor)
end
test "succeeds when required boolean custom field has true value",
%{
required_boolean_field: field
required_boolean_field: field,
actor: actor
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
@ -332,12 +363,13 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
assert {:ok, _member} = Membership.create_member(attrs, actor: actor)
end
test "fails when required date custom field has nil value",
%{
required_date_field: field
required_date_field: field,
actor: actor
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
@ -351,14 +383,17 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_member(attrs, actor: actor)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "fails when required date custom field has empty string value",
%{
required_date_field: field
required_date_field: field,
actor: actor
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
@ -372,25 +407,29 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_member(attrs, actor: actor)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "succeeds when required date custom field has valid date value",
%{
required_date_field: _field
required_date_field: _field,
actor: actor
} = context do
custom_field_values = all_required_custom_fields_with_defaults(context)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
assert {:ok, _member} = Membership.create_member(attrs, actor: actor)
end
test "fails when required email custom field has nil value",
%{
required_email_field: field
required_email_field: field,
actor: actor
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
@ -404,14 +443,17 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_member(attrs, actor: actor)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "fails when required email custom field has empty string value",
%{
required_email_field: field
required_email_field: field,
actor: actor
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
@ -425,27 +467,31 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_member(attrs, actor: actor)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
end
test "succeeds when required email custom field has valid email value",
%{
required_email_field: _field
required_email_field: _field,
actor: actor
} = context do
custom_field_values = all_required_custom_fields_with_defaults(context)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
assert {:ok, _member} = Membership.create_member(attrs, actor: actor)
end
test "succeeds when multiple required custom fields are provided",
%{
required_string_field: string_field,
required_integer_field: integer_field,
required_boolean_field: boolean_field
required_boolean_field: boolean_field,
actor: actor
} = context do
custom_field_values =
all_required_custom_fields_with_defaults(context)
@ -467,13 +513,14 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
assert {:ok, _member} = Membership.create_member(attrs, actor: actor)
end
test "fails when one of multiple required custom fields is missing",
%{
required_string_field: string_field,
required_integer_field: integer_field
required_integer_field: integer_field,
actor: actor
} = context do
# Provide only string field, missing integer, boolean, and date
custom_field_values =
@ -487,22 +534,24 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_member(attrs, actor: actor)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ integer_field.name
end
test "succeeds when optional custom field is missing", %{} = context do
test "succeeds when optional custom field is missing", %{actor: actor} = context do
# Provide all required fields, but no optional field
custom_field_values = all_required_custom_fields_with_defaults(context)
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
assert {:ok, _member} = Membership.create_member(attrs, actor: actor)
end
test "succeeds when optional custom field has nil value",
%{optional_field: field} = context do
%{optional_field: field, actor: actor} = context do
# Provide all required fields plus optional field with nil
custom_field_values =
all_required_custom_fields_with_defaults(context) ++
@ -515,29 +564,33 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
assert {:ok, _member} = Membership.create_member(attrs)
assert {:ok, _member} = Membership.create_member(attrs, actor: actor)
end
end
describe "update_member with required custom fields" do
test "fails when removing a required custom field value",
%{
required_string_field: field
required_string_field: field,
actor: actor
} = context do
# Create member with all required custom fields
custom_field_values = all_required_custom_fields_with_defaults(context)
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "john@example.com",
custom_field_values: custom_field_values
})
Membership.create_member(
%{
first_name: "John",
last_name: "Doe",
email: "john@example.com",
custom_field_values: custom_field_values
},
actor: actor
)
# Try to update without the required custom field
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.update_member(member, %{custom_field_values: []})
Membership.update_member(member, %{custom_field_values: []}, actor: actor)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
@ -545,18 +598,22 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
test "fails when setting required custom field value to empty",
%{
required_string_field: field
required_string_field: field,
actor: actor
} = context do
# Create member with all required custom fields
custom_field_values = all_required_custom_fields_with_defaults(context)
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "john@example.com",
custom_field_values: custom_field_values
})
Membership.create_member(
%{
first_name: "John",
last_name: "Doe",
email: "john@example.com",
custom_field_values: custom_field_values
},
actor: actor
)
# Try to update with empty value for the string field
updated_custom_field_values =
@ -570,9 +627,13 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
end)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.update_member(member, %{
custom_field_values: updated_custom_field_values
})
Membership.update_member(
member,
%{
custom_field_values: updated_custom_field_values
},
actor: actor
)
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
assert error_message(errors, :custom_field_values) =~ field.name
@ -580,21 +641,25 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
test "succeeds when updating required custom field to valid value",
%{
required_string_field: field
required_string_field: field,
actor: actor
} = context do
# Create member with all required custom fields
custom_field_values = all_required_custom_fields_with_defaults(context)
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "john@example.com",
custom_field_values: custom_field_values
})
Membership.create_member(
%{
first_name: "John",
last_name: "Doe",
email: "john@example.com",
custom_field_values: custom_field_values
},
actor: actor
)
# Load existing custom field values to get their IDs
{:ok, member_with_cfvs} = Ash.load(member, :custom_field_values)
{:ok, member_with_cfvs} = Ash.load(member, :custom_field_values, actor: actor)
# Update with new valid value for the string field, using existing IDs
updated_custom_field_values =
@ -620,9 +685,13 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
end)
assert {:ok, _updated_member} =
Membership.update_member(member, %{
custom_field_values: updated_custom_field_values
})
Membership.update_member(
member,
%{
custom_field_values: updated_custom_field_values
},
actor: actor
)
end
end

View file

@ -10,6 +10,8 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test members
{:ok, member1} =
Member
@ -18,7 +20,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
last_name: "Anderson",
email: "alice@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, member2} =
Member
@ -27,7 +29,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
last_name: "Brown",
email: "bob@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, member3} =
Member
@ -36,7 +38,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
last_name: "Clark",
email: "charlie@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Create custom fields for different types
{:ok, string_field} =
@ -45,7 +47,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
name: "membership_number",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, integer_field} =
CustomField
@ -53,7 +55,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
name: "member_id_number",
value_type: :integer
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, email_field} =
CustomField
@ -61,7 +63,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
name: "secondary_email",
value_type: :email
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, date_field} =
CustomField
@ -69,7 +71,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
name: "birthday",
value_type: :date
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, boolean_field} =
CustomField
@ -77,7 +79,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
name: "newsletter",
value_type: :boolean
})
|> Ash.create()
|> Ash.create(actor: system_actor)
%{
member1: member1,
@ -87,12 +89,14 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
integer_field: integer_field,
email_field: email_field,
date_field: date_field,
boolean_field: boolean_field
boolean_field: boolean_field,
system_actor: system_actor
}
end
describe "search with custom field values" do
test "finds member by string custom field value", %{
system_actor: system_actor,
member1: member1,
string_field: string_field
} do
@ -104,25 +108,26 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "MEMBER12345"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Force search_vector update by reloading member
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
|> Ash.update(actor: system_actor)
# Search for the custom field value
results =
Member
|> Member.fuzzy_search(%{query: "MEMBER12345"})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
assert length(results) == 1
assert List.first(results).id == member1.id
end
test "finds member by integer custom field value", %{
system_actor: system_actor,
member1: member1,
integer_field: integer_field
} do
@ -134,25 +139,26 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
custom_field_id: integer_field.id,
value: %{"_union_type" => "integer", "_union_value" => 42_424}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
|> Ash.update(actor: system_actor)
# Search for the custom field value
results =
Member
|> Member.fuzzy_search(%{query: "42424"})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
assert length(results) == 1
assert List.first(results).id == member1.id
end
test "finds member by email custom field value", %{
system_actor: system_actor,
member1: member1,
email_field: email_field
} do
@ -164,19 +170,19 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
custom_field_id: email_field.id,
value: %{"_union_type" => "email", "_union_value" => "alice.secondary@example.com"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
|> Ash.update(actor: system_actor)
# Search for partial custom field value (should work via FTS or custom field filter)
results =
Member
|> Member.fuzzy_search(%{query: "alice.secondary"})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
assert length(results) == 1
assert List.first(results).id == member1.id
@ -185,7 +191,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
results_full =
Member
|> Member.fuzzy_search(%{query: "alice.secondary@example.com"})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
assert length(results_full) == 1
assert List.first(results_full).id == member1.id
@ -195,7 +201,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
results_domain =
Member
|> Member.fuzzy_search(%{query: "example.com"})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
# Verify that member1 is in the results (may have other members too)
ids = Enum.map(results_domain, & &1.id)
@ -203,6 +209,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
end
test "finds member by date custom field value", %{
system_actor: system_actor,
member1: member1,
date_field: date_field
} do
@ -214,25 +221,26 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
custom_field_id: date_field.id,
value: %{"_union_type" => "date", "_union_value" => ~D[1990-05-15]}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
|> Ash.update(actor: system_actor)
# Search for the custom field value (date is stored as text in search_vector)
results =
Member
|> Member.fuzzy_search(%{query: "1990-05-15"})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
assert length(results) == 1
assert List.first(results).id == member1.id
end
test "finds member by boolean custom field value", %{
system_actor: system_actor,
member1: member1,
boolean_field: boolean_field
} do
@ -244,25 +252,26 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
custom_field_id: boolean_field.id,
value: %{"_union_type" => "boolean", "_union_value" => true}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
|> Ash.update(actor: system_actor)
# Search for the custom field value (boolean is stored as "true" or "false" text)
results =
Member
|> Member.fuzzy_search(%{query: "true"})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
# Note: "true" might match other things, so we check that member1 is in results
assert Enum.any?(results, fn m -> m.id == member1.id end)
end
test "custom field value update triggers search_vector update", %{
system_actor: system_actor,
member1: member1,
string_field: string_field
} do
@ -274,13 +283,13 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "OLDVALUE"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
|> Ash.update(actor: system_actor)
# Update custom field value
{:ok, _updated_cfv} =
@ -288,13 +297,13 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|> Ash.Changeset.for_update(:update, %{
value: %{"_union_type" => "string", "_union_value" => "NEWVALUE123"}
})
|> Ash.update()
|> Ash.update(actor: system_actor)
# Search for the new value
results =
Member
|> Member.fuzzy_search(%{query: "NEWVALUE123"})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
assert length(results) == 1
assert List.first(results).id == member1.id
@ -303,12 +312,13 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
old_results =
Member
|> Member.fuzzy_search(%{query: "OLDVALUE"})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
refute Enum.any?(old_results, fn m -> m.id == member1.id end)
end
test "custom field value delete triggers search_vector update", %{
system_actor: system_actor,
member1: member1,
string_field: string_field
} do
@ -320,19 +330,19 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "TOBEDELETED"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
|> Ash.update(actor: system_actor)
# Verify it's searchable
results =
Member
|> Member.fuzzy_search(%{query: "TOBEDELETED"})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
assert length(results) == 1
assert List.first(results).id == member1.id
@ -344,12 +354,13 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
deleted_results =
Member
|> Member.fuzzy_search(%{query: "TOBEDELETED"})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
refute Enum.any?(deleted_results, fn m -> m.id == member1.id end)
end
test "custom field value create triggers search_vector update", %{
system_actor: system_actor,
member1: member1,
string_field: string_field
} do
@ -361,19 +372,20 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "AUTOUPDATE"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Search should find it immediately (trigger should have updated search_vector)
results =
Member
|> Member.fuzzy_search(%{query: "AUTOUPDATE"})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
assert length(results) == 1
assert List.first(results).id == member1.id
end
test "member update includes custom field values in search_vector", %{
system_actor: system_actor,
member1: member1,
string_field: string_field
} do
@ -385,25 +397,26 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "MEMBERUPDATE"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Update member (should trigger search_vector update including custom fields)
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{notes: "Updated notes"})
|> Ash.update()
|> Ash.update(actor: system_actor)
# Search should find the custom field value
results =
Member
|> Member.fuzzy_search(%{query: "MEMBERUPDATE"})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
assert length(results) == 1
assert List.first(results).id == member1.id
end
test "multiple custom field values are all searchable", %{
system_actor: system_actor,
member1: member1,
string_field: string_field,
integer_field: integer_field,
@ -417,7 +430,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "MULTI1"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, _cfv2} =
CustomFieldValue
@ -426,7 +439,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
custom_field_id: integer_field.id,
value: %{"_union_type" => "integer", "_union_value" => 99_999}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, _cfv3} =
CustomFieldValue
@ -435,38 +448,39 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
custom_field_id: email_field.id,
value: %{"_union_type" => "email", "_union_value" => "multi@test.com"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
|> Ash.update(actor: system_actor)
# All values should be searchable
results1 =
Member
|> Member.fuzzy_search(%{query: "MULTI1"})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
assert Enum.any?(results1, fn m -> m.id == member1.id end)
results2 =
Member
|> Member.fuzzy_search(%{query: "99999"})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
assert Enum.any?(results2, fn m -> m.id == member1.id end)
results3 =
Member
|> Member.fuzzy_search(%{query: "multi@test.com"})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
assert Enum.any?(results3, fn m -> m.id == member1.id end)
end
test "finds member by custom field value with numbers in text field (e.g. phone number)", %{
system_actor: system_actor,
member1: member1,
string_field: string_field
} do
@ -478,19 +492,19 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "M-123-456"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
|> Ash.update(actor: system_actor)
# Search for full value (should work via search_vector)
results_full =
Member
|> Member.fuzzy_search(%{query: "M-123-456"})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
assert Enum.any?(results_full, fn m -> m.id == member1.id end),
"Full value search should find member via search_vector"
@ -501,6 +515,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
end
test "finds member by phone number in Emergency Contact custom field", %{
system_actor: system_actor,
member1: member1
} do
# Create Emergency Contact custom field
@ -510,7 +525,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
name: "Emergency Contact",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Create custom field value with phone number
phone_number = "+49 123 456789"
@ -522,19 +537,19 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
custom_field_id: emergency_contact_field.id,
value: %{"_union_type" => "string", "_union_value" => phone_number}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update()
|> Ash.update(actor: system_actor)
# Search for full phone number (should work via search_vector)
results_full =
Member
|> Member.fuzzy_search(%{query: phone_number})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
assert Enum.any?(results_full, fn m -> m.id == member1.id end),
"Full phone number search should find member via search_vector"
@ -547,6 +562,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
describe "custom field substring search (ILIKE)" do
test "finds member by prefix of custom field value", %{
system_actor: system_actor,
member1: member1,
string_field: string_field
} do
@ -558,14 +574,14 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "Premium"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Test prefix searches - should all find the member
for prefix <- ["Premium", "Premiu", "Premi", "Prem", "Pre"] do
results =
Member
|> Member.fuzzy_search(%{query: prefix})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
assert Enum.any?(results, fn m -> m.id == member1.id end),
"Prefix '#{prefix}' should find member with custom field 'Premium'"
@ -573,6 +589,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
end
test "custom field search is case-insensitive", %{
system_actor: system_actor,
member1: member1,
string_field: string_field
} do
@ -584,7 +601,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "GoldMember"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Test case variations - should all find the member
for variant <- [
@ -599,7 +616,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
results =
Member
|> Member.fuzzy_search(%{query: variant})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
assert Enum.any?(results, fn m -> m.id == member1.id end),
"Case variant '#{variant}' should find member with custom field 'GoldMember'"
@ -607,6 +624,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
end
test "finds member by suffix/middle of custom field value", %{
system_actor: system_actor,
member1: member1,
string_field: string_field
} do
@ -618,14 +636,14 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "ActiveMember"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Test suffix and middle substring searches
for substring <- ["Member", "ember", "tiveMem", "ctive"] do
results =
Member
|> Member.fuzzy_search(%{query: substring})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
assert Enum.any?(results, fn m -> m.id == member1.id end),
"Substring '#{substring}' should find member with custom field 'ActiveMember'"
@ -633,6 +651,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
end
test "finds correct member among multiple with different custom field values", %{
system_actor: system_actor,
member1: member1,
member2: member2,
member3: member3,
@ -646,7 +665,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "Beginner"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, _cfv2} =
CustomFieldValue
@ -655,7 +674,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "Advanced"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, _cfv3} =
CustomFieldValue
@ -664,13 +683,13 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "Expert"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Search for "Begin" - should only find member1
results_begin =
Member
|> Member.fuzzy_search(%{query: "Begin"})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
assert length(results_begin) == 1
assert List.first(results_begin).id == member1.id
@ -679,7 +698,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
results_advan =
Member
|> Member.fuzzy_search(%{query: "Advan"})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
assert length(results_advan) == 1
assert List.first(results_advan).id == member2.id
@ -688,7 +707,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
results_exper =
Member
|> Member.fuzzy_search(%{query: "Exper"})
|> Ash.read!()
|> Ash.read!(actor: system_actor)
assert length(results_exper) == 1
assert List.first(results_exper).id == member3.id

View file

@ -2,6 +2,11 @@ defmodule Mv.Membership.MemberTest do
use Mv.DataCase, async: false
alias Mv.Membership
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "Fields and Validations" do
@valid_attrs %{
first_name: "John",
@ -16,60 +21,74 @@ defmodule Mv.Membership.MemberTest do
postal_code: "12345"
}
test "First name is optional" do
test "First name is optional", %{actor: actor} do
attrs = Map.delete(@valid_attrs, :first_name)
assert {:ok, _member} = Membership.create_member(attrs)
assert {:ok, _member} = Membership.create_member(attrs, actor: actor)
end
test "Last name is optional" do
test "Last name is optional", %{actor: actor} do
attrs = Map.delete(@valid_attrs, :last_name)
assert {:ok, _member} = Membership.create_member(attrs)
assert {:ok, _member} = Membership.create_member(attrs, actor: actor)
end
test "Email is required" do
test "Email is required", %{actor: actor} do
attrs = Map.put(@valid_attrs, :email, "")
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_member(attrs, actor: actor)
assert error_message(errors, :email) =~ "must be present"
end
test "Email must be valid" do
test "Email must be valid", %{actor: actor} do
attrs = Map.put(@valid_attrs, :email, "test@")
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_member(attrs, actor: actor)
assert error_message(errors, :email) =~ "is not a valid email"
end
test "Join date cannot be in the future" do
test "Join date cannot be in the future", %{actor: actor} do
attrs = Map.put(@valid_attrs, :join_date, Date.utc_today() |> Date.add(1))
assert {:error,
%Ash.Error.Invalid{errors: [%Ash.Error.Changes.InvalidAttribute{field: :join_date}]}} =
Membership.create_member(attrs)
Membership.create_member(attrs, actor: actor)
end
test "Exit date is optional but must not be before join date if both are specified" do
test "Exit date is optional but must not be before join date if both are specified", %{
actor: actor
} do
attrs = Map.put(@valid_attrs, :exit_date, ~D[2010-01-01])
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_member(attrs, actor: actor)
assert error_message(errors, :exit_date) =~ "cannot be before join date"
attrs2 = Map.delete(@valid_attrs, :exit_date)
assert {:ok, _member} = Membership.create_member(attrs2)
assert {:ok, _member} = Membership.create_member(attrs2, actor: actor)
end
test "Notes is optional" do
test "Notes is optional", %{actor: actor} do
attrs = Map.delete(@valid_attrs, :notes)
assert {:ok, _member} = Membership.create_member(attrs)
assert {:ok, _member} = Membership.create_member(attrs, actor: actor)
end
test "City, street, house number are optional" do
test "City, street, house number are optional", %{actor: actor} do
attrs = @valid_attrs |> Map.drop([:city, :street, :house_number])
assert {:ok, _member} = Membership.create_member(attrs)
assert {:ok, _member} = Membership.create_member(attrs, actor: actor)
end
test "Postal code is optional but must have 5 digits if specified" do
test "Postal code is optional but must have 5 digits if specified", %{actor: actor} do
attrs = Map.put(@valid_attrs, :postal_code, "1234")
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_member(attrs, actor: actor)
assert error_message(errors, :postal_code) =~ "must consist of 5 digits"
attrs2 = Map.delete(@valid_attrs, :postal_code)
assert {:ok, _member} = Membership.create_member(attrs2)
assert {:ok, _member} = Membership.create_member(attrs2, actor: actor)
end
end

View file

@ -11,8 +11,13 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
require Ash.Query
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
# Helper to create a membership fee type
defp create_fee_type(attrs) do
defp create_fee_type(attrs, actor) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
@ -23,11 +28,11 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
|> Ash.create!(actor: actor)
end
# Helper to create a member
defp create_member(attrs) do
defp create_member(attrs, actor) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
@ -39,11 +44,11 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
|> Ash.create!(actor: actor)
end
# Helper to create a cycle
defp create_cycle(member, fee_type, attrs) do
defp create_cycle(member, fee_type, attrs, actor) do
default_attrs = %{
cycle_start: ~D[2024-01-01],
amount: Decimal.new("50.00"),
@ -56,17 +61,17 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
|> Ash.create!(actor: actor)
end
describe "type change cycle regeneration" do
test "future unpaid cycles are regenerated with new amount" do
test "future unpaid cycles are regenerated with new amount", %{actor: actor} do
today = Date.utc_today()
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")}, actor)
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")}, actor)
# Create member without fee type first to avoid auto-generation
member = create_member(%{})
member = create_member(%{}, actor)
# Manually assign fee type (this will trigger cycle generation)
member =
@ -74,7 +79,7 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!()
|> Ash.update!(actor: actor)
# Cycle generation runs synchronously in the same transaction
# No need to wait for async completion
@ -89,26 +94,31 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
# Check if it already exists (from auto-generation), if not create it
case MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start)
|> Ash.read_one() do
|> Ash.read_one(actor: actor) do
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
# Update to paid
existing_cycle
|> Ash.Changeset.for_update(:update, %{status: :paid})
|> Ash.update!()
|> Ash.update!(actor: actor)
_ ->
create_cycle(member, yearly_type1, %{
cycle_start: past_cycle_start,
status: :paid,
amount: Decimal.new("100.00")
})
create_cycle(
member,
yearly_type1,
%{
cycle_start: past_cycle_start,
status: :paid,
amount: Decimal.new("100.00")
},
actor
)
end
# Current cycle (unpaid) - should be regenerated
# Delete if exists (from auto-generation), then create with old amount
case MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one() do
|> Ash.read_one(actor: actor) do
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
Ash.destroy!(existing_cycle)
@ -117,11 +127,16 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
end
_current_cycle =
create_cycle(member, yearly_type1, %{
cycle_start: current_cycle_start,
status: :unpaid,
amount: Decimal.new("100.00")
})
create_cycle(
member,
yearly_type1,
%{
cycle_start: current_cycle_start,
status: :unpaid,
amount: Decimal.new("100.00")
},
actor
)
# Change membership fee type (same interval, different amount)
assert {:ok, _updated_member} =
@ -129,7 +144,7 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update()
|> Ash.update(actor: actor)
# Cycle regeneration runs synchronously in the same transaction
# No need to wait for async completion
@ -138,7 +153,7 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
past_cycle_after =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start)
|> Ash.read_one!()
|> Ash.read_one!(actor: actor)
assert past_cycle_after.status == :paid
assert Decimal.equal?(past_cycle_after.amount, Decimal.new("100.00"))
@ -149,7 +164,7 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
new_current_cycle =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one!()
|> Ash.read_one!(actor: actor)
# Verify it has the new type and amount
assert new_current_cycle.membership_fee_type_id == yearly_type2.id
@ -163,18 +178,18 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
member_id == ^member.id and cycle_start == ^current_cycle_start and
membership_fee_type_id == ^yearly_type1.id
)
|> Ash.read!()
|> Ash.read!(actor: actor)
assert Enum.empty?(old_current_cycles)
end
test "paid cycles remain unchanged" do
test "paid cycles remain unchanged", %{actor: actor} do
today = Date.utc_today()
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")}, actor)
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")}, actor)
# Create member without fee type first to avoid auto-generation
member = create_member(%{})
member = create_member(%{}, actor)
# Manually assign fee type (this will trigger cycle generation)
member =
@ -182,7 +197,7 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!()
|> Ash.update!(actor: actor)
# Cycle generation runs synchronously in the same transaction
# No need to wait for async completion
@ -194,9 +209,9 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
paid_cycle =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one!()
|> Ash.read_one!(actor: actor)
|> Ash.Changeset.for_update(:mark_as_paid)
|> Ash.update!()
|> Ash.update!(actor: actor)
# Change membership fee type
assert {:ok, _updated_member} =
@ -204,25 +219,25 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update()
|> Ash.update(actor: actor)
# Cycle regeneration runs synchronously in the same transaction
# No need to wait for async completion
# Verify paid cycle is unchanged (not deleted and regenerated)
{:ok, cycle_after} = Ash.get(MembershipFeeCycle, paid_cycle.id)
{:ok, cycle_after} = Ash.get(MembershipFeeCycle, paid_cycle.id, actor: actor)
assert cycle_after.status == :paid
assert Decimal.equal?(cycle_after.amount, Decimal.new("100.00"))
assert cycle_after.membership_fee_type_id == yearly_type1.id
end
test "suspended cycles remain unchanged" do
test "suspended cycles remain unchanged", %{actor: actor} do
today = Date.utc_today()
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")}, actor)
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")}, actor)
# Create member without fee type first to avoid auto-generation
member = create_member(%{})
member = create_member(%{}, actor)
# Manually assign fee type (this will trigger cycle generation)
member =
@ -230,7 +245,7 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!()
|> Ash.update!(actor: actor)
# Cycle generation runs synchronously in the same transaction
# No need to wait for async completion
@ -242,9 +257,9 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
suspended_cycle =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one!()
|> Ash.read_one!(actor: actor)
|> Ash.Changeset.for_update(:mark_as_suspended)
|> Ash.update!()
|> Ash.update!(actor: actor)
# Change membership fee type
assert {:ok, _updated_member} =
@ -252,25 +267,25 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update()
|> Ash.update(actor: actor)
# Cycle regeneration runs synchronously in the same transaction
# No need to wait for async completion
# Verify suspended cycle is unchanged (not deleted and regenerated)
{:ok, cycle_after} = Ash.get(MembershipFeeCycle, suspended_cycle.id)
{:ok, cycle_after} = Ash.get(MembershipFeeCycle, suspended_cycle.id, actor: actor)
assert cycle_after.status == :suspended
assert Decimal.equal?(cycle_after.amount, Decimal.new("100.00"))
assert cycle_after.membership_fee_type_id == yearly_type1.id
end
test "only cycles that haven't ended yet are deleted" do
test "only cycles that haven't ended yet are deleted", %{actor: actor} do
today = Date.utc_today()
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")}, actor)
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")}, actor)
# Create member without fee type first to avoid auto-generation
member = create_member(%{})
member = create_member(%{}, actor)
# Manually assign fee type (this will trigger cycle generation)
member =
@ -278,7 +293,7 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!()
|> Ash.update!(actor: actor)
# Cycle generation runs synchronously in the same transaction
# No need to wait for async completion
@ -296,7 +311,7 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
# Delete existing cycle if it exists (from auto-generation)
case MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start)
|> Ash.read_one() do
|> Ash.read_one(actor: actor) do
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
Ash.destroy!(existing_cycle)
@ -305,17 +320,22 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
end
past_cycle =
create_cycle(member, yearly_type1, %{
cycle_start: past_cycle_start,
status: :unpaid,
amount: Decimal.new("100.00")
})
create_cycle(
member,
yearly_type1,
%{
cycle_start: past_cycle_start,
status: :unpaid,
amount: Decimal.new("100.00")
},
actor
)
# Current cycle (unpaid) - should be regenerated (cycle_start >= today)
# Delete existing cycle if it exists (from auto-generation)
case MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one() do
|> Ash.read_one(actor: actor) do
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
Ash.destroy!(existing_cycle)
@ -324,11 +344,16 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
end
_current_cycle =
create_cycle(member, yearly_type1, %{
cycle_start: current_cycle_start,
status: :unpaid,
amount: Decimal.new("100.00")
})
create_cycle(
member,
yearly_type1,
%{
cycle_start: current_cycle_start,
status: :unpaid,
amount: Decimal.new("100.00")
},
actor
)
# Change membership fee type
assert {:ok, _updated_member} =
@ -336,13 +361,13 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update()
|> Ash.update(actor: actor)
# Cycle regeneration runs synchronously in the same transaction
# No need to wait for async completion
# Verify past cycle is unchanged
{:ok, past_cycle_after} = Ash.get(MembershipFeeCycle, past_cycle.id)
{:ok, past_cycle_after} = Ash.get(MembershipFeeCycle, past_cycle.id, actor: actor)
assert past_cycle_after.status == :unpaid
assert Decimal.equal?(past_cycle_after.amount, Decimal.new("100.00"))
assert past_cycle_after.membership_fee_type_id == yearly_type1.id
@ -352,7 +377,7 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
new_current_cycle =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one!()
|> Ash.read_one!(actor: actor)
assert new_current_cycle.membership_fee_type_id == yearly_type2.id
assert Decimal.equal?(new_current_cycle.amount, Decimal.new("150.00"))
@ -364,19 +389,19 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
member_id == ^member.id and cycle_start == ^current_cycle_start and
membership_fee_type_id == ^yearly_type1.id
)
|> Ash.read!()
|> Ash.read!(actor: actor)
assert Enum.empty?(old_current_cycles)
end
test "member calculations update after type change" do
test "member calculations update after type change", %{actor: actor} do
today = Date.utc_today()
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")}, actor)
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")}, actor)
# Create member with join_date = today to avoid past cycles
# This ensures no overdue cycles exist
member = create_member(%{join_date: today})
member = create_member(%{join_date: today}, actor)
# Manually assign fee type (this will trigger cycle generation)
member =
@ -384,7 +409,7 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!()
|> Ash.update!(actor: actor)
# Cycle generation runs synchronously in the same transaction
# No need to wait for async completion
@ -397,7 +422,7 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
existing_cycles =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
|> Ash.read!(actor: actor)
Enum.each(existing_cycles, fn cycle ->
if cycle.cycle_start != current_cycle_start do
@ -408,22 +433,27 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
# Ensure current cycle exists and is unpaid
case MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one() do
|> Ash.read_one(actor: actor) do
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
# Update to unpaid if it's not
if existing_cycle.status != :unpaid do
existing_cycle
|> Ash.Changeset.for_update(:mark_as_unpaid)
|> Ash.update!()
|> Ash.update!(actor: actor)
end
_ ->
# Create if it doesn't exist
create_cycle(member, yearly_type1, %{
cycle_start: current_cycle_start,
status: :unpaid,
amount: Decimal.new("100.00")
})
create_cycle(
member,
yearly_type1,
%{
cycle_start: current_cycle_start,
status: :unpaid,
amount: Decimal.new("100.00")
},
actor
)
end
# Load calculations before change
@ -437,7 +467,7 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update()
|> Ash.update(actor: actor)
# Cycle regeneration runs synchronously in the same transaction
# No need to wait for async completion

View file

@ -7,6 +7,11 @@ defmodule Mv.Membership.MembershipFeeSettingsTest do
alias Mv.Membership.Setting
alias Mv.MembershipFees.MembershipFeeType
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "membership fee settings" do
test "default values are correct" do
{:ok, settings} = Mv.Membership.get_settings()
@ -18,7 +23,7 @@ defmodule Mv.Membership.MembershipFeeSettingsTest do
assert %Setting{} = settings
end
test "settings can be written via update_membership_fee_settings" do
test "settings can be written via update_membership_fee_settings", %{actor: actor} do
{:ok, settings} = Mv.Membership.get_settings()
{:ok, updated} =
@ -26,12 +31,12 @@ defmodule Mv.Membership.MembershipFeeSettingsTest do
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
include_joining_cycle: false
})
|> Ash.update()
|> Ash.update(actor: actor)
assert updated.include_joining_cycle == false
end
test "default_membership_fee_type_id can be nil (optional)" do
test "default_membership_fee_type_id can be nil (optional)", %{actor: actor} do
{:ok, settings} = Mv.Membership.get_settings()
{:ok, updated} =
@ -39,12 +44,12 @@ defmodule Mv.Membership.MembershipFeeSettingsTest do
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
default_membership_fee_type_id: nil
})
|> Ash.update()
|> Ash.update(actor: actor)
assert updated.default_membership_fee_type_id == nil
end
test "default_membership_fee_type_id validation: must exist if set" do
test "default_membership_fee_type_id validation: must exist if set", %{actor: actor} do
{:ok, settings} = Mv.Membership.get_settings()
# Create a valid fee type
@ -61,12 +66,12 @@ defmodule Mv.Membership.MembershipFeeSettingsTest do
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
default_membership_fee_type_id: fee_type.id
})
|> Ash.update()
|> Ash.update(actor: actor)
assert updated.default_membership_fee_type_id == fee_type.id
end
test "default_membership_fee_type_id validation: fails if not found" do
test "default_membership_fee_type_id validation: fails if not found", %{actor: actor} do
{:ok, settings} = Mv.Membership.get_settings()
# Use a non-existent UUID
@ -77,7 +82,7 @@ defmodule Mv.Membership.MembershipFeeSettingsTest do
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
default_membership_fee_type_id: fake_uuid
})
|> Ash.update()
|> Ash.update(actor: actor)
assert error_on_field?(error, :default_membership_fee_type_id)
end

View file

@ -6,13 +6,18 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
alias Mv.MembershipFees.Changes.SetMembershipFeeStartDate
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
# Helper to set up settings with specific include_joining_cycle value
defp setup_settings(include_joining_cycle) do
defp setup_settings(include_joining_cycle, actor) do
{:ok, settings} = Mv.Membership.get_settings()
settings
|> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle})
|> Ash.update!()
|> Ash.update!(actor: actor)
end
describe "calculate_start_date/3" do
@ -127,8 +132,8 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
end
describe "change/3 integration" do
test "sets membership_fee_start_date automatically on member creation" do
setup_settings(true)
test "sets membership_fee_start_date automatically on member creation", %{actor: actor} do
setup_settings(true, actor)
# Create a fee type
fee_type =
@ -138,7 +143,7 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
amount: Decimal.new("50.00"),
interval: :yearly
})
|> Ash.create!()
|> Ash.create!(actor: actor)
# Create member with join_date and fee type but no explicit start date
member =
@ -150,14 +155,14 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
join_date: ~D[2024-03-15],
membership_fee_type_id: fee_type.id
})
|> Ash.create!()
|> Ash.create!(actor: actor)
# Should have auto-calculated start date (2024-01-01 for yearly with include_joining_cycle=true)
assert member.membership_fee_start_date == ~D[2024-01-01]
end
test "does not override manually set membership_fee_start_date" do
setup_settings(true)
test "does not override manually set membership_fee_start_date", %{actor: actor} do
setup_settings(true, actor)
# Create a fee type
fee_type =
@ -167,7 +172,7 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
amount: Decimal.new("50.00"),
interval: :yearly
})
|> Ash.create!()
|> Ash.create!(actor: actor)
# Create member with explicit start date
manual_start_date = ~D[2024-07-01]
@ -182,14 +187,14 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
membership_fee_type_id: fee_type.id,
membership_fee_start_date: manual_start_date
})
|> Ash.create!()
|> Ash.create!(actor: actor)
# Should keep the manually set date
assert member.membership_fee_start_date == manual_start_date
end
test "respects include_joining_cycle = false setting" do
setup_settings(false)
test "respects include_joining_cycle = false setting", %{actor: actor} do
setup_settings(false, actor)
# Create a fee type
fee_type =
@ -199,7 +204,7 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
amount: Decimal.new("50.00"),
interval: :yearly
})
|> Ash.create!()
|> Ash.create!(actor: actor)
# Create member
member =
@ -211,14 +216,14 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
join_date: ~D[2024-03-15],
membership_fee_type_id: fee_type.id
})
|> Ash.create!()
|> Ash.create!(actor: actor)
# Should have next cycle start date (2025-01-01 for yearly with include_joining_cycle=false)
assert member.membership_fee_start_date == ~D[2025-01-01]
end
test "does not set start date without join_date" do
setup_settings(true)
test "does not set start date without join_date", %{actor: actor} do
setup_settings(true, actor)
# Create a fee type
fee_type =
@ -228,7 +233,7 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
amount: Decimal.new("50.00"),
interval: :yearly
})
|> Ash.create!()
|> Ash.create!(actor: actor)
# Create member without join_date
member =
@ -240,14 +245,14 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
membership_fee_type_id: fee_type.id
# No join_date
})
|> Ash.create!()
|> Ash.create!(actor: actor)
# Should not have auto-calculated start date
assert is_nil(member.membership_fee_start_date)
end
test "does not set start date without membership_fee_type_id" do
setup_settings(true)
test "does not set start date without membership_fee_type_id", %{actor: actor} do
setup_settings(true, actor)
# Create member without fee type
member =
@ -259,7 +264,7 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
join_date: ~D[2024-03-15]
# No membership_fee_type_id
})
|> Ash.create!()
|> Ash.create!(actor: actor)
# Should not have auto-calculated start date
assert is_nil(member.membership_fee_start_date)

View file

@ -8,8 +8,13 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.Changes.ValidateSameInterval
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
# Helper to create a membership fee type
defp create_fee_type(attrs) do
defp create_fee_type(attrs, actor) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
@ -20,11 +25,11 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
|> Ash.create!(actor: actor)
end
# Helper to create a member
defp create_member(attrs) do
defp create_member(attrs, actor) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
@ -35,15 +40,15 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
|> Ash.create!(actor: actor)
end
describe "validate_interval_match/1" do
test "allows change to type with same interval" do
yearly_type1 = create_fee_type(%{interval: :yearly, name: "Yearly Type 1"})
yearly_type2 = create_fee_type(%{interval: :yearly, name: "Yearly Type 2"})
test "allows change to type with same interval", %{actor: actor} do
yearly_type1 = create_fee_type(%{interval: :yearly, name: "Yearly Type 1"}, actor)
yearly_type2 = create_fee_type(%{interval: :yearly, name: "Yearly Type 2"}, actor)
member = create_member(%{membership_fee_type_id: yearly_type1.id})
member = create_member(%{membership_fee_type_id: yearly_type1.id}, actor)
changeset =
member
@ -55,11 +60,11 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
assert changeset.valid?
end
test "prevents change to type with different interval" do
yearly_type = create_fee_type(%{interval: :yearly})
monthly_type = create_fee_type(%{interval: :monthly})
test "prevents change to type with different interval", %{actor: actor} do
yearly_type = create_fee_type(%{interval: :yearly}, actor)
monthly_type = create_fee_type(%{interval: :monthly}, actor)
member = create_member(%{membership_fee_type_id: yearly_type.id})
member = create_member(%{membership_fee_type_id: yearly_type.id}, actor)
changeset =
member
@ -78,10 +83,10 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
end)
end
test "allows first assignment of membership fee type" do
yearly_type = create_fee_type(%{interval: :yearly})
test "allows first assignment of membership fee type", %{actor: actor} do
yearly_type = create_fee_type(%{interval: :yearly}, actor)
# No fee type assigned
member = create_member(%{})
member = create_member(%{}, actor)
changeset =
member
@ -93,9 +98,9 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
assert changeset.valid?
end
test "prevents removal of membership fee type" do
yearly_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: yearly_type.id})
test "prevents removal of membership fee type", %{actor: actor} do
yearly_type = create_fee_type(%{interval: :yearly}, actor)
member = create_member(%{membership_fee_type_id: yearly_type.id}, actor)
changeset =
member
@ -113,9 +118,9 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
end)
end
test "does nothing when membership_fee_type_id is not changed" do
yearly_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: yearly_type.id})
test "does nothing when membership_fee_type_id is not changed", %{actor: actor} do
yearly_type = create_fee_type(%{interval: :yearly}, actor)
member = create_member(%{membership_fee_type_id: yearly_type.id}, actor)
changeset =
member
@ -127,11 +132,11 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
assert changeset.valid?
end
test "error message is clear and helpful" do
yearly_type = create_fee_type(%{interval: :yearly})
quarterly_type = create_fee_type(%{interval: :quarterly})
test "error message is clear and helpful", %{actor: actor} do
yearly_type = create_fee_type(%{interval: :yearly}, actor)
quarterly_type = create_fee_type(%{interval: :quarterly}, actor)
member = create_member(%{membership_fee_type_id: yearly_type.id})
member = create_member(%{membership_fee_type_id: yearly_type.id}, actor)
changeset =
member
@ -146,25 +151,31 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
assert error.message =~ "same-interval"
end
test "handles all interval types correctly" do
test "handles all interval types correctly", %{actor: actor} do
intervals = [:monthly, :quarterly, :half_yearly, :yearly]
for interval1 <- intervals,
interval2 <- intervals,
interval1 != interval2 do
type1 =
create_fee_type(%{
interval: interval1,
name: "Type #{interval1} #{System.unique_integer([:positive])}"
})
create_fee_type(
%{
interval: interval1,
name: "Type #{interval1} #{System.unique_integer([:positive])}"
},
actor
)
type2 =
create_fee_type(%{
interval: interval2,
name: "Type #{interval2} #{System.unique_integer([:positive])}"
})
create_fee_type(
%{
interval: interval2,
name: "Type #{interval2} #{System.unique_integer([:positive])}"
},
actor
)
member = create_member(%{membership_fee_type_id: type1.id})
member = create_member(%{membership_fee_type_id: type1.id}, actor)
changeset =
member
@ -180,11 +191,11 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
end
describe "integration with update_member action" do
test "validation works when updating member via update_member action" do
yearly_type = create_fee_type(%{interval: :yearly})
monthly_type = create_fee_type(%{interval: :monthly})
test "validation works when updating member via update_member action", %{actor: actor} do
yearly_type = create_fee_type(%{interval: :yearly}, actor)
monthly_type = create_fee_type(%{interval: :monthly}, actor)
member = create_member(%{membership_fee_type_id: yearly_type.id})
member = create_member(%{membership_fee_type_id: yearly_type.id}, actor)
# Try to update member with different interval type
assert {:error, %Ash.Error.Invalid{} = error} =
@ -192,7 +203,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: monthly_type.id
})
|> Ash.update()
|> Ash.update(actor: actor)
# Check that error is about interval mismatch
error_message = extract_error_message(error)
@ -201,11 +212,11 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
assert error_message =~ "same-interval"
end
test "allows update when interval matches" do
yearly_type1 = create_fee_type(%{interval: :yearly, name: "Yearly Type 1"})
yearly_type2 = create_fee_type(%{interval: :yearly, name: "Yearly Type 2"})
test "allows update when interval matches", %{actor: actor} do
yearly_type1 = create_fee_type(%{interval: :yearly, name: "Yearly Type 1"}, actor)
yearly_type2 = create_fee_type(%{interval: :yearly, name: "Yearly Type 2"}, actor)
member = create_member(%{membership_fee_type_id: yearly_type1.id})
member = create_member(%{membership_fee_type_id: yearly_type1.id}, actor)
# Update member with same-interval type
assert {:ok, updated_member} =
@ -213,7 +224,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update()
|> Ash.update(actor: actor)
assert updated_member.membership_fee_type_id == yearly_type2.id
end

View file

@ -8,211 +8,287 @@ defmodule Mv.MembershipFees.ForeignKeyTest do
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership.Member
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "CASCADE behavior" do
test "deleting member deletes associated membership_fee_cycles" do
test "deleting member deletes associated membership_fee_cycles", %{actor: actor} do
# Create member
{:ok, member} =
Ash.create(Member, %{
first_name: "Cascade",
last_name: "Test",
email: "cascade.test.#{System.unique_integer([:positive])}@example.com"
})
Ash.create(
Member,
%{
first_name: "Cascade",
last_name: "Test",
email: "cascade.test.#{System.unique_integer([:positive])}@example.com"
},
actor: actor
)
# Create fee type
{:ok, fee_type} =
Ash.create(MembershipFeeType, %{
name: "Cascade Test Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :monthly
})
Ash.create(
MembershipFeeType,
%{
name: "Cascade Test Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :monthly
},
actor: actor
)
# Create multiple cycles for this member
{:ok, cycle1} =
Ash.create(MembershipFeeCycle, %{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
})
Ash.create(
MembershipFeeCycle,
%{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
},
actor: actor
)
{:ok, cycle2} =
Ash.create(MembershipFeeCycle, %{
cycle_start: ~D[2025-02-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
})
Ash.create(
MembershipFeeCycle,
%{
cycle_start: ~D[2025-02-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
},
actor: actor
)
# Verify cycles exist
assert {:ok, _} = Ash.get(MembershipFeeCycle, cycle1.id)
assert {:ok, _} = Ash.get(MembershipFeeCycle, cycle2.id)
assert {:ok, _} = Ash.get(MembershipFeeCycle, cycle1.id, actor: actor)
assert {:ok, _} = Ash.get(MembershipFeeCycle, cycle2.id, actor: actor)
# Delete member
assert :ok = Ash.destroy(member)
assert :ok = Ash.destroy(member, actor: actor)
# Verify cycles are also deleted (CASCADE)
# NotFound is wrapped in Ash.Error.Invalid
assert {:error, %Ash.Error.Invalid{}} = Ash.get(MembershipFeeCycle, cycle1.id)
assert {:error, %Ash.Error.Invalid{}} = Ash.get(MembershipFeeCycle, cycle2.id)
assert {:error, %Ash.Error.Invalid{}} = Ash.get(MembershipFeeCycle, cycle1.id, actor: actor)
assert {:error, %Ash.Error.Invalid{}} = Ash.get(MembershipFeeCycle, cycle2.id, actor: actor)
end
end
describe "RESTRICT behavior" do
test "cannot delete membership_fee_type if cycles reference it" do
test "cannot delete membership_fee_type if cycles reference it", %{actor: actor} do
# Create member
{:ok, member} =
Ash.create(Member, %{
first_name: "Restrict",
last_name: "Test",
email: "restrict.test.#{System.unique_integer([:positive])}@example.com"
})
Ash.create(
Member,
%{
first_name: "Restrict",
last_name: "Test",
email: "restrict.test.#{System.unique_integer([:positive])}@example.com"
},
actor: actor
)
# Create fee type
{:ok, fee_type} =
Ash.create(MembershipFeeType, %{
name: "Restrict Test Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :monthly
})
Ash.create(
MembershipFeeType,
%{
name: "Restrict Test Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :monthly
},
actor: actor
)
# Create a cycle referencing this fee type
{:ok, _cycle} =
Ash.create(MembershipFeeCycle, %{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
})
Ash.create(
MembershipFeeCycle,
%{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
},
actor: actor
)
# Try to delete fee type - should fail due to RESTRICT
assert {:error, error} = Ash.destroy(fee_type)
assert {:error, error} = Ash.destroy(fee_type, actor: actor)
# Check that it's a foreign key violation error
assert is_struct(error, Ash.Error.Invalid) or is_struct(error, Ash.Error.Unknown)
end
test "can delete membership_fee_type if no cycles reference it" do
test "can delete membership_fee_type if no cycles reference it", %{actor: actor} do
# Create fee type without any cycles
{:ok, fee_type} =
Ash.create(MembershipFeeType, %{
name: "Deletable Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :monthly
})
Ash.create(
MembershipFeeType,
%{
name: "Deletable Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :monthly
},
actor: actor
)
# Should be able to delete
assert :ok = Ash.destroy(fee_type)
assert :ok = Ash.destroy(fee_type, actor: actor)
# Verify it's gone (NotFound is wrapped in Ash.Error.Invalid)
assert {:error, %Ash.Error.Invalid{}} = Ash.get(MembershipFeeType, fee_type.id)
assert {:error, %Ash.Error.Invalid{}} =
Ash.get(MembershipFeeType, fee_type.id, actor: actor)
end
test "cannot delete membership_fee_type if members reference it" do
test "cannot delete membership_fee_type if members reference it", %{actor: actor} do
# Create fee type
{:ok, fee_type} =
Ash.create(MembershipFeeType, %{
name: "Member Ref Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :monthly
})
Ash.create(
MembershipFeeType,
%{
name: "Member Ref Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :monthly
},
actor: actor
)
# Create member with this fee type
{:ok, _member} =
Ash.create(Member, %{
first_name: "FeeType",
last_name: "Reference",
email: "feetype.ref.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
})
Ash.create(
Member,
%{
first_name: "FeeType",
last_name: "Reference",
email: "feetype.ref.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
},
actor: actor
)
# Try to delete fee type - should fail due to RESTRICT
assert {:error, error} = Ash.destroy(fee_type)
assert {:error, error} = Ash.destroy(fee_type, actor: actor)
assert is_struct(error, Ash.Error.Invalid) or is_struct(error, Ash.Error.Unknown)
end
end
describe "member extensions" do
test "member can be created with membership_fee_type_id" do
test "member can be created with membership_fee_type_id", %{actor: actor} do
# Create fee type first
{:ok, fee_type} =
Ash.create(MembershipFeeType, %{
name: "Create Test Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :yearly
})
Ash.create(
MembershipFeeType,
%{
name: "Create Test Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :yearly
},
actor: actor
)
# Create member with fee type
{:ok, member} =
Ash.create(Member, %{
first_name: "With",
last_name: "FeeType",
email: "with.feetype.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
})
Ash.create(
Member,
%{
first_name: "With",
last_name: "FeeType",
email: "with.feetype.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
},
actor: actor
)
assert member.membership_fee_type_id == fee_type.id
end
test "member can be created with membership_fee_start_date" do
test "member can be created with membership_fee_start_date", %{actor: actor} do
{:ok, member} =
Ash.create(Member, %{
first_name: "With",
last_name: "StartDate",
email: "with.startdate.#{System.unique_integer([:positive])}@example.com",
membership_fee_start_date: ~D[2025-01-01]
})
Ash.create(
Member,
%{
first_name: "With",
last_name: "StartDate",
email: "with.startdate.#{System.unique_integer([:positive])}@example.com",
membership_fee_start_date: ~D[2025-01-01]
},
actor: actor
)
assert member.membership_fee_start_date == ~D[2025-01-01]
end
test "member can be created without membership fee fields" do
test "member can be created without membership fee fields", %{actor: actor} do
{:ok, member} =
Ash.create(Member, %{
first_name: "No",
last_name: "FeeFields",
email: "no.feefields.#{System.unique_integer([:positive])}@example.com"
})
Ash.create(
Member,
%{
first_name: "No",
last_name: "FeeFields",
email: "no.feefields.#{System.unique_integer([:positive])}@example.com"
},
actor: actor
)
assert member.membership_fee_type_id == nil
assert member.membership_fee_start_date == nil
end
test "member can be updated with membership_fee_type_id" do
test "member can be updated with membership_fee_type_id", %{actor: actor} do
# Create fee type
{:ok, fee_type} =
Ash.create(MembershipFeeType, %{
name: "Update Test Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :yearly
})
Ash.create(
MembershipFeeType,
%{
name: "Update Test Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :yearly
},
actor: actor
)
# Create member without fee type
{:ok, member} =
Ash.create(Member, %{
first_name: "Update",
last_name: "Test",
email: "update.test.#{System.unique_integer([:positive])}@example.com"
})
Ash.create(
Member,
%{
first_name: "Update",
last_name: "Test",
email: "update.test.#{System.unique_integer([:positive])}@example.com"
},
actor: actor
)
assert member.membership_fee_type_id == nil
# Update member with fee type
{:ok, updated_member} = Ash.update(member, %{membership_fee_type_id: fee_type.id})
{:ok, updated_member} =
Ash.update(member, %{membership_fee_type_id: fee_type.id}, actor: actor)
assert updated_member.membership_fee_type_id == fee_type.id
end
test "member can be updated with membership_fee_start_date" do
test "member can be updated with membership_fee_start_date", %{actor: actor} do
{:ok, member} =
Ash.create(Member, %{
first_name: "Start",
last_name: "Date",
email: "start.date.#{System.unique_integer([:positive])}@example.com"
})
Ash.create(
Member,
%{
first_name: "Start",
last_name: "Date",
email: "start.date.#{System.unique_integer([:positive])}@example.com"
},
actor: actor
)
assert member.membership_fee_start_date == nil
{:ok, updated_member} = Ash.update(member, %{membership_fee_start_date: ~D[2025-06-01]})
{:ok, updated_member} =
Ash.update(member, %{membership_fee_start_date: ~D[2025-06-01]}, actor: actor)
assert updated_member.membership_fee_start_date == ~D[2025-06-01]
end

View file

@ -10,8 +10,13 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
require Ash.Query
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
# Helper to create a membership fee type
defp create_fee_type(attrs) do
defp create_fee_type(attrs, actor) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
@ -22,30 +27,30 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
|> Ash.create!(actor: actor)
end
# Helper to set up settings
defp setup_settings(include_joining_cycle) do
defp setup_settings(include_joining_cycle, actor) do
{:ok, settings} = Mv.Membership.get_settings()
settings
|> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle})
|> Ash.update!()
|> Ash.update!(actor: actor)
end
# Helper to get cycles for a member
defp get_member_cycles(member_id) do
defp get_member_cycles(member_id, actor) do
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member_id)
|> Ash.Query.sort(cycle_start: :asc)
|> Ash.read!()
|> Ash.read!(actor: actor)
end
describe "member creation triggers cycle generation" do
test "creates cycles when member is created with fee type and join_date" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
test "creates cycles when member is created with fee type and join_date", %{actor: actor} do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :yearly}, actor)
member =
Member
@ -56,9 +61,9 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
join_date: ~D[2023-03-15],
membership_fee_type_id: fee_type.id
})
|> Ash.create!()
|> Ash.create!(actor: actor)
cycles = get_member_cycles(member.id)
cycles = get_member_cycles(member.id, actor)
# Should have cycles for 2023 and 2024 (and possibly current year)
assert length(cycles) >= 2
@ -72,8 +77,8 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
end)
end
test "does not create cycles when member has no fee type" do
setup_settings(true)
test "does not create cycles when member has no fee type", %{actor: actor} do
setup_settings(true, actor)
member =
Member
@ -84,16 +89,16 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
join_date: ~D[2023-03-15]
# No membership_fee_type_id
})
|> Ash.create!()
|> Ash.create!(actor: actor)
cycles = get_member_cycles(member.id)
cycles = get_member_cycles(member.id, actor)
assert cycles == []
end
test "does not create cycles when member has no join_date" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
test "does not create cycles when member has no join_date", %{actor: actor} do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :yearly}, actor)
member =
Member
@ -104,18 +109,18 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
membership_fee_type_id: fee_type.id
# No join_date
})
|> Ash.create!()
|> Ash.create!(actor: actor)
cycles = get_member_cycles(member.id)
cycles = get_member_cycles(member.id, actor)
assert cycles == []
end
end
describe "member update triggers cycle generation" do
test "generates cycles when fee type is assigned to existing member" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
test "generates cycles when fee type is assigned to existing member", %{actor: actor} do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :yearly}, actor)
# Create member without fee type
member =
@ -126,17 +131,17 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-03-15]
})
|> Ash.create!()
|> Ash.create!(actor: actor)
# Verify no cycles yet
assert get_member_cycles(member.id) == []
assert get_member_cycles(member.id, actor) == []
# Update to assign fee type
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
|> Ash.update!(actor: actor)
cycles = get_member_cycles(member.id)
cycles = get_member_cycles(member.id, actor)
# Should have generated cycles
assert length(cycles) >= 2
@ -144,9 +149,9 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
end
describe "concurrent cycle generation" do
test "handles multiple members being created concurrently" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
test "handles multiple members being created concurrently", %{actor: actor} do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :yearly}, actor)
# Create multiple members concurrently
tasks =
@ -160,7 +165,7 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
join_date: ~D[2023-03-15],
membership_fee_type_id: fee_type.id
})
|> Ash.create!()
|> Ash.create!(actor: actor)
end)
end)
@ -168,16 +173,16 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
# Each member should have cycles
Enum.each(members, fn member ->
cycles = get_member_cycles(member.id)
cycles = get_member_cycles(member.id, actor)
assert length(cycles) >= 2, "Member #{member.id} should have at least 2 cycles"
end)
end
end
describe "idempotent cycle generation" do
test "running generation multiple times does not create duplicate cycles" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
test "running generation multiple times does not create duplicate cycles", %{actor: actor} do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :yearly}, actor)
member =
Member
@ -188,9 +193,9 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
join_date: ~D[2023-03-15],
membership_fee_type_id: fee_type.id
})
|> Ash.create!()
|> Ash.create!(actor: actor)
initial_cycles = get_member_cycles(member.id)
initial_cycles = get_member_cycles(member.id, actor)
initial_count = length(initial_cycles)
# Use a fixed "today" date to avoid date dependency
@ -201,7 +206,7 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
{:ok, _, _} =
Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id, today: today)
final_cycles = get_member_cycles(member.id)
final_cycles = get_member_cycles(member.id, actor)
final_count = length(final_cycles)
# Should have same number of cycles (idempotent)

View file

@ -8,8 +8,13 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership.Member
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
# Helper to create a membership fee type
defp create_fee_type(attrs) do
defp create_fee_type(attrs, actor) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
@ -20,11 +25,11 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
|> Ash.create!(actor: actor)
end
# Helper to create a member
defp create_member(attrs) do
defp create_member(attrs, actor) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
@ -35,11 +40,11 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
|> Ash.create!(actor: actor)
end
# Helper to create a cycle
defp create_cycle(member, fee_type, attrs) do
defp create_cycle(member, fee_type, attrs, actor) do
default_attrs = %{
cycle_start: ~D[2024-01-01],
amount: Decimal.new("50.00"),
@ -51,13 +56,13 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
|> Ash.create!(actor: actor)
end
describe "status defaults" do
test "status defaults to :unpaid when creating a cycle" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
test "status defaults to :unpaid when creating a cycle", %{actor: actor} do
fee_type = create_fee_type(%{interval: :yearly}, actor)
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
cycle =
MembershipFeeCycle
@ -67,29 +72,30 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
member_id: member.id,
membership_fee_type_id: fee_type.id
})
|> Ash.create!()
|> Ash.create!(actor: actor)
assert cycle.status == :unpaid
end
end
describe "mark_as_paid" do
test "sets status to :paid" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :unpaid})
test "sets status to :paid", %{actor: actor} do
fee_type = create_fee_type(%{interval: :yearly}, actor)
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
cycle = create_cycle(member, fee_type, %{status: :unpaid}, actor)
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_paid)
assert {:ok, updated} = Ash.update(cycle, %{}, actor: actor, action: :mark_as_paid)
assert updated.status == :paid
end
test "can set notes when marking as paid" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :unpaid})
test "can set notes when marking as paid", %{actor: actor} do
fee_type = create_fee_type(%{interval: :yearly}, actor)
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
cycle = create_cycle(member, fee_type, %{status: :unpaid}, actor)
assert {:ok, updated} =
Ash.update(cycle, %{notes: "Payment received via bank transfer"},
actor: actor,
action: :mark_as_paid
)
@ -97,33 +103,34 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
assert updated.notes == "Payment received via bank transfer"
end
test "can change from suspended to paid" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :suspended})
test "can change from suspended to paid", %{actor: actor} do
fee_type = create_fee_type(%{interval: :yearly}, actor)
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
cycle = create_cycle(member, fee_type, %{status: :suspended}, actor)
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_paid)
assert {:ok, updated} = Ash.update(cycle, %{}, actor: actor, action: :mark_as_paid)
assert updated.status == :paid
end
end
describe "mark_as_suspended" do
test "sets status to :suspended" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :unpaid})
test "sets status to :suspended", %{actor: actor} do
fee_type = create_fee_type(%{interval: :yearly}, actor)
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
cycle = create_cycle(member, fee_type, %{status: :unpaid}, actor)
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_suspended)
assert {:ok, updated} = Ash.update(cycle, %{}, actor: actor, action: :mark_as_suspended)
assert updated.status == :suspended
end
test "can set notes when marking as suspended" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :unpaid})
test "can set notes when marking as suspended", %{actor: actor} do
fee_type = create_fee_type(%{interval: :yearly}, actor)
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
cycle = create_cycle(member, fee_type, %{status: :unpaid}, actor)
assert {:ok, updated} =
Ash.update(cycle, %{notes: "Waived due to special circumstances"},
actor: actor,
action: :mark_as_suspended
)
@ -131,42 +138,45 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
assert updated.notes == "Waived due to special circumstances"
end
test "can change from paid to suspended" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :paid})
test "can change from paid to suspended", %{actor: actor} do
fee_type = create_fee_type(%{interval: :yearly}, actor)
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
cycle = create_cycle(member, fee_type, %{status: :paid}, actor)
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_suspended)
assert {:ok, updated} = Ash.update(cycle, %{}, actor: actor, action: :mark_as_suspended)
assert updated.status == :suspended
end
end
describe "mark_as_unpaid" do
test "sets status to :unpaid" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :paid})
test "sets status to :unpaid", %{actor: actor} do
fee_type = create_fee_type(%{interval: :yearly}, actor)
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
cycle = create_cycle(member, fee_type, %{status: :paid}, actor)
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_unpaid)
assert updated.status == :unpaid
end
test "can set notes when marking as unpaid" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :paid})
test "can set notes when marking as unpaid", %{actor: actor} do
fee_type = create_fee_type(%{interval: :yearly}, actor)
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
cycle = create_cycle(member, fee_type, %{status: :paid}, actor)
assert {:ok, updated} =
Ash.update(cycle, %{notes: "Payment was reversed"}, action: :mark_as_unpaid)
Ash.update(cycle, %{notes: "Payment was reversed"},
actor: actor,
action: :mark_as_unpaid
)
assert updated.status == :unpaid
assert updated.notes == "Payment was reversed"
end
test "can change from suspended to unpaid" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :suspended})
test "can change from suspended to unpaid", %{actor: actor} do
fee_type = create_fee_type(%{interval: :yearly}, actor)
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
cycle = create_cycle(member, fee_type, %{status: :suspended}, actor)
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_unpaid)
assert updated.status == :unpaid
@ -174,12 +184,12 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
end
describe "status transitions" do
test "all status transitions are allowed" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
test "all status transitions are allowed", %{actor: actor} do
fee_type = create_fee_type(%{interval: :yearly}, actor)
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
# unpaid -> paid
cycle1 = create_cycle(member, fee_type, %{status: :unpaid})
cycle1 = create_cycle(member, fee_type, %{status: :unpaid}, actor)
assert {:ok, c1} = Ash.update(cycle1, %{}, action: :mark_as_paid)
assert c1.status == :paid

View file

@ -10,8 +10,13 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
require Ash.Query
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
# Helper to create a membership fee type
defp create_fee_type(attrs) do
defp create_fee_type(attrs, actor) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
@ -22,11 +27,11 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
|> Ash.create!(actor: actor)
end
describe "admin can create membership fee type" do
test "creates type with all fields" do
test "creates type with all fields", %{actor: actor} do
attrs = %{
name: "Standard Membership",
amount: Decimal.new("120.00"),
@ -34,7 +39,8 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
description: "Standard yearly membership fee"
}
assert {:ok, %MembershipFeeType{} = fee_type} = Ash.create(MembershipFeeType, attrs)
assert {:ok, %MembershipFeeType{} = fee_type} =
Ash.create(MembershipFeeType, attrs, actor: actor)
assert fee_type.name == "Standard Membership"
assert Decimal.equal?(fee_type.amount, Decimal.new("120.00"))
@ -44,88 +50,106 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
end
describe "admin can update membership fee type" do
setup do
setup %{actor: actor} do
{:ok, fee_type} =
Ash.create(MembershipFeeType, %{
name: "Original Name",
amount: Decimal.new("100.00"),
interval: :yearly,
description: "Original description"
})
Ash.create(
MembershipFeeType,
%{
name: "Original Name",
amount: Decimal.new("100.00"),
interval: :yearly,
description: "Original description"
},
actor: actor
)
%{fee_type: fee_type}
end
test "can update name", %{fee_type: fee_type} do
assert {:ok, updated} = Ash.update(fee_type, %{name: "Updated Name"})
test "can update name", %{actor: actor, fee_type: fee_type} do
assert {:ok, updated} = Ash.update(fee_type, %{name: "Updated Name"}, actor: actor)
assert updated.name == "Updated Name"
end
test "can update amount", %{fee_type: fee_type} do
assert {:ok, updated} = Ash.update(fee_type, %{amount: Decimal.new("150.00")})
test "can update amount", %{actor: actor, fee_type: fee_type} do
assert {:ok, updated} = Ash.update(fee_type, %{amount: Decimal.new("150.00")}, actor: actor)
assert Decimal.equal?(updated.amount, Decimal.new("150.00"))
end
test "can update description", %{fee_type: fee_type} do
assert {:ok, updated} = Ash.update(fee_type, %{description: "Updated description"})
test "can update description", %{actor: actor, fee_type: fee_type} do
assert {:ok, updated} =
Ash.update(fee_type, %{description: "Updated description"}, actor: actor)
assert updated.description == "Updated description"
end
test "cannot update interval", %{fee_type: fee_type} do
test "cannot update interval", %{actor: actor, fee_type: fee_type} do
# Currently, interval is not in the accept list, so it's rejected as "NoSuchInput"
# After implementing validation, it should return a validation error
assert {:error, error} = Ash.update(fee_type, %{interval: :monthly})
assert {:error, error} = Ash.update(fee_type, %{interval: :monthly}, actor: actor)
# For now, check that it's an error (either NoSuchInput or validation error)
assert %Ash.Error.Invalid{} = error
end
end
describe "admin cannot delete membership fee type when in use" do
test "cannot delete when members are assigned" do
fee_type = create_fee_type(%{interval: :yearly})
test "cannot delete when members are assigned", %{actor: actor} do
fee_type = create_fee_type(%{interval: :yearly}, actor)
# Create a member with this fee type
{:ok, _member} =
Ash.create(Member, %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
})
Ash.create(
Member,
%{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
},
actor: actor
)
assert {:error, error} = Ash.destroy(fee_type)
assert {:error, error} = Ash.destroy(fee_type, actor: actor)
error_message = extract_error_message(error)
assert error_message =~ "member(s) are assigned"
end
test "cannot delete when cycles exist" do
fee_type = create_fee_type(%{interval: :yearly})
test "cannot delete when cycles exist", %{actor: actor} do
fee_type = create_fee_type(%{interval: :yearly}, actor)
# Create a member with this fee type
{:ok, member} =
Ash.create(Member, %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
})
Ash.create(
Member,
%{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
},
actor: actor
)
# Create a cycle for this fee type
{:ok, _cycle} =
Ash.create(MembershipFeeCycle, %{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
})
Ash.create(
MembershipFeeCycle,
%{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
},
actor: actor
)
assert {:error, error} = Ash.destroy(fee_type)
assert {:error, error} = Ash.destroy(fee_type, actor: actor)
error_message = extract_error_message(error)
assert error_message =~ "cycle(s) reference"
end
test "cannot delete when used as default in settings" do
fee_type = create_fee_type(%{interval: :yearly})
test "cannot delete when used as default in settings", %{actor: actor} do
fee_type = create_fee_type(%{interval: :yearly}, actor)
# Set as default in settings
{:ok, settings} = Mv.Membership.get_settings()
@ -134,19 +158,19 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
default_membership_fee_type_id: fee_type.id
})
|> Ash.update!()
|> Ash.update!(actor: actor)
# Try to delete
assert {:error, error} = Ash.destroy(fee_type)
assert {:error, error} = Ash.destroy(fee_type, actor: actor)
error_message = extract_error_message(error)
assert error_message =~ "used as default in settings"
end
end
describe "settings integration" do
test "default_membership_fee_type_id is used during member creation" do
test "default_membership_fee_type_id is used during member creation", %{actor: actor} do
# Create a fee type
fee_type = create_fee_type(%{interval: :yearly})
fee_type = create_fee_type(%{interval: :yearly}, actor)
# Set it as default in settings
{:ok, settings} = Mv.Membership.get_settings()
@ -155,29 +179,33 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
default_membership_fee_type_id: fee_type.id
})
|> Ash.update!()
|> Ash.update!(actor: actor)
# Create a member without explicitly setting membership_fee_type_id
# The Member resource automatically assigns the default_membership_fee_type_id
# during creation via SetDefaultMembershipFeeType change.
{:ok, member} =
Ash.create(Member, %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
})
Ash.create(
Member,
%{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
},
actor: actor
)
# Verify that the default membership fee type was automatically assigned
assert member.membership_fee_type_id == fee_type.id
end
test "include_joining_cycle is used during cycle generation" do
test "include_joining_cycle is used during cycle generation", %{actor: actor} do
# This test verifies that the include_joining_cycle setting affects
# cycle generation. The actual cycle generation logic is tested in
# CycleGeneratorTest, but this integration test ensures the setting
# is properly used.
fee_type = create_fee_type(%{interval: :yearly})
fee_type = create_fee_type(%{interval: :yearly}, actor)
# Set include_joining_cycle to false
{:ok, settings} = Mv.Membership.get_settings()
@ -186,17 +214,21 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
include_joining_cycle: false
})
|> Ash.update!()
|> Ash.update!(actor: actor)
# Create a member with join_date in the middle of a year
{:ok, member} =
Ash.create(Member, %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-03-15],
membership_fee_type_id: fee_type.id
})
Ash.create(
Member,
%{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-03-15],
membership_fee_type_id: fee_type.id
},
actor: actor
)
# Verify that membership_fee_start_date was calculated correctly
# (should be 2024-01-01, not 2023-01-01, because include_joining_cycle = false)

View file

@ -6,8 +6,13 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
alias Mv.MembershipFees.MembershipFeeType
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "create MembershipFeeType" do
test "can create membership fee type with valid attributes" do
test "can create membership fee type with valid attributes", %{actor: actor} do
attrs = %{
name: "Standard Membership",
amount: Decimal.new("120.00"),
@ -16,7 +21,7 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
}
assert {:ok, %MembershipFeeType{} = fee_type} =
Ash.create(MembershipFeeType, attrs)
Ash.create(MembershipFeeType, attrs, actor: actor)
assert fee_type.name == "Standard Membership"
assert Decimal.equal?(fee_type.amount, Decimal.new("120.00"))
@ -24,212 +29,237 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
assert fee_type.description == "Standard yearly membership fee"
end
test "can create membership fee type without description" do
test "can create membership fee type without description", %{actor: actor} do
attrs = %{
name: "Basic",
amount: Decimal.new("60.00"),
interval: :monthly
}
assert {:ok, %MembershipFeeType{}} = Ash.create(MembershipFeeType, attrs)
assert {:ok, %MembershipFeeType{}} = Ash.create(MembershipFeeType, attrs, actor: actor)
end
test "requires name" do
test "requires name", %{actor: actor} do
attrs = %{
amount: Decimal.new("100.00"),
interval: :yearly
}
assert {:error, error} = Ash.create(MembershipFeeType, attrs)
assert {:error, error} = Ash.create(MembershipFeeType, attrs, actor: actor)
assert error_on_field?(error, :name)
end
test "requires amount" do
test "requires amount", %{actor: actor} do
attrs = %{
name: "Test Fee",
interval: :yearly
}
assert {:error, error} = Ash.create(MembershipFeeType, attrs)
assert {:error, error} = Ash.create(MembershipFeeType, attrs, actor: actor)
assert error_on_field?(error, :amount)
end
test "requires interval" do
test "requires interval", %{actor: actor} do
attrs = %{
name: "Test Fee",
amount: Decimal.new("100.00")
}
assert {:error, error} = Ash.create(MembershipFeeType, attrs)
assert {:error, error} = Ash.create(MembershipFeeType, attrs, actor: actor)
assert error_on_field?(error, :interval)
end
test "validates interval enum values - monthly" do
test "validates interval enum values - monthly", %{actor: actor} do
attrs = %{name: "Monthly", amount: Decimal.new("10.00"), interval: :monthly}
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs)
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs, actor: actor)
assert fee_type.interval == :monthly
end
test "validates interval enum values - quarterly" do
test "validates interval enum values - quarterly", %{actor: actor} do
attrs = %{name: "Quarterly", amount: Decimal.new("30.00"), interval: :quarterly}
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs)
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs, actor: actor)
assert fee_type.interval == :quarterly
end
test "validates interval enum values - half_yearly" do
test "validates interval enum values - half_yearly", %{actor: actor} do
attrs = %{name: "Half Yearly", amount: Decimal.new("60.00"), interval: :half_yearly}
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs)
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs, actor: actor)
assert fee_type.interval == :half_yearly
end
test "validates interval enum values - yearly" do
test "validates interval enum values - yearly", %{actor: actor} do
attrs = %{name: "Yearly", amount: Decimal.new("120.00"), interval: :yearly}
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs)
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs, actor: actor)
assert fee_type.interval == :yearly
end
test "rejects invalid interval values" do
test "rejects invalid interval values", %{actor: actor} do
attrs = %{name: "Invalid", amount: Decimal.new("100.00"), interval: :weekly}
assert {:error, error} = Ash.create(MembershipFeeType, attrs)
assert {:error, error} = Ash.create(MembershipFeeType, attrs, actor: actor)
assert error_on_field?(error, :interval)
end
test "name must be unique" do
test "name must be unique", %{actor: actor} do
attrs = %{name: "Unique Name", amount: Decimal.new("100.00"), interval: :yearly}
assert {:ok, _} = Ash.create(MembershipFeeType, attrs)
assert {:error, error} = Ash.create(MembershipFeeType, attrs)
assert {:ok, _} = Ash.create(MembershipFeeType, attrs, actor: actor)
assert {:error, error} = Ash.create(MembershipFeeType, attrs, actor: actor)
# Check for uniqueness error
assert error_on_field?(error, :name)
end
test "rejects negative amount" do
test "rejects negative amount", %{actor: actor} do
attrs = %{name: "Negative Test", amount: Decimal.new("-10.00"), interval: :yearly}
assert {:error, error} = Ash.create(MembershipFeeType, attrs)
assert {:error, error} = Ash.create(MembershipFeeType, attrs, actor: actor)
assert error_on_field?(error, :amount)
end
test "accepts zero amount" do
test "accepts zero amount", %{actor: actor} do
attrs = %{name: "Zero Amount", amount: Decimal.new("0.00"), interval: :yearly}
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs)
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs, actor: actor)
assert Decimal.equal?(fee_type.amount, Decimal.new("0.00"))
end
test "amount respects scale of 2 decimal places" do
test "amount respects scale of 2 decimal places", %{actor: actor} do
attrs = %{name: "Scale Test", amount: Decimal.new("100.50"), interval: :yearly}
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs)
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs, actor: actor)
assert Decimal.equal?(fee_type.amount, Decimal.new("100.50"))
end
end
describe "update MembershipFeeType" do
setup do
setup %{actor: actor} do
{:ok, fee_type} =
Ash.create(MembershipFeeType, %{
name: "Original Name",
amount: Decimal.new("100.00"),
interval: :yearly,
description: "Original description"
})
Ash.create(
MembershipFeeType,
%{
name: "Original Name",
amount: Decimal.new("100.00"),
interval: :yearly,
description: "Original description"
},
actor: actor
)
%{fee_type: fee_type}
end
test "can update name", %{fee_type: fee_type} do
assert {:ok, updated} = Ash.update(fee_type, %{name: "Updated Name"})
test "can update name", %{actor: actor, fee_type: fee_type} do
assert {:ok, updated} = Ash.update(fee_type, %{name: "Updated Name"}, actor: actor)
assert updated.name == "Updated Name"
end
test "can update amount", %{fee_type: fee_type} do
assert {:ok, updated} = Ash.update(fee_type, %{amount: Decimal.new("150.00")})
test "can update amount", %{actor: actor, fee_type: fee_type} do
assert {:ok, updated} = Ash.update(fee_type, %{amount: Decimal.new("150.00")}, actor: actor)
assert Decimal.equal?(updated.amount, Decimal.new("150.00"))
end
test "can update description", %{fee_type: fee_type} do
assert {:ok, updated} = Ash.update(fee_type, %{description: "Updated description"})
test "can update description", %{actor: actor, fee_type: fee_type} do
assert {:ok, updated} =
Ash.update(fee_type, %{description: "Updated description"}, actor: actor)
assert updated.description == "Updated description"
end
test "can clear description", %{fee_type: fee_type} do
assert {:ok, updated} = Ash.update(fee_type, %{description: nil})
test "can clear description", %{actor: actor, fee_type: fee_type} do
assert {:ok, updated} = Ash.update(fee_type, %{description: nil}, actor: actor)
assert updated.description == nil
end
test "interval immutability: update fails when interval is changed", %{fee_type: fee_type} do
test "interval immutability: update fails when interval is changed", %{
actor: actor,
fee_type: fee_type
} do
# Currently, interval is not in the accept list, so it's rejected as "NoSuchInput"
# After implementing validation, it should return a validation error
assert {:error, error} = Ash.update(fee_type, %{interval: :monthly})
assert {:error, error} = Ash.update(fee_type, %{interval: :monthly}, actor: actor)
# For now, check that it's an error (either NoSuchInput or validation error)
assert %Ash.Error.Invalid{} = error
end
end
describe "delete MembershipFeeType" do
setup do
setup %{actor: actor} do
{:ok, fee_type} =
Ash.create(MembershipFeeType, %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :yearly
})
Ash.create(
MembershipFeeType,
%{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :yearly
},
actor: actor
)
%{fee_type: fee_type}
end
test "can delete when not in use", %{fee_type: fee_type} do
result = Ash.destroy(fee_type)
test "can delete when not in use", %{actor: actor, fee_type: fee_type} do
result = Ash.destroy(fee_type, actor: actor)
# Ash.destroy returns :ok or {:ok, _} depending on version
assert result == :ok or match?({:ok, _}, result)
end
test "cannot delete when members are assigned", %{fee_type: fee_type} do
test "cannot delete when members are assigned", %{actor: actor, fee_type: fee_type} do
alias Mv.Membership.Member
# Create a member with this fee type
{:ok, _member} =
Ash.create(Member, %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
})
Ash.create(
Member,
%{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
},
actor: actor
)
assert {:error, error} = Ash.destroy(fee_type)
assert {:error, error} = Ash.destroy(fee_type, actor: actor)
# Check for either validation error message or DB constraint error
error_message = extract_error_message(error)
assert error_message =~ "member" or error_message =~ "referenced"
end
test "cannot delete when cycles exist", %{fee_type: fee_type} do
test "cannot delete when cycles exist", %{actor: actor, fee_type: fee_type} do
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.Membership.Member
# Create a member with this fee type
{:ok, member} =
Ash.create(Member, %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
})
Ash.create(
Member,
%{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
},
actor: actor
)
# Create a cycle for this fee type
{:ok, _cycle} =
Ash.create(MembershipFeeCycle, %{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
})
Ash.create(
MembershipFeeCycle,
%{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
},
actor: actor
)
assert {:error, error} = Ash.destroy(fee_type)
assert {:error, error} = Ash.destroy(fee_type, actor: actor)
# Check for either validation error message or DB constraint error
error_message = extract_error_message(error)
assert error_message =~ "cycle" or error_message =~ "referenced"
end
test "cannot delete when used as default in settings", %{fee_type: fee_type} do
test "cannot delete when used as default in settings", %{actor: actor, fee_type: fee_type} do
# Set as default in settings
{:ok, settings} = Mv.Membership.get_settings()
@ -237,10 +267,10 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
default_membership_fee_type_id: fee_type.id
})
|> Ash.update!()
|> Ash.update!(actor: actor)
# Try to delete
assert {:error, error} = Ash.destroy(fee_type)
assert {:error, error} = Ash.destroy(fee_type, actor: actor)
error_message = extract_error_message(error)
assert error_message =~ "used as default in settings"
end

View file

@ -14,6 +14,11 @@ defmodule Mv.Accounts.UserPoliciesTest do
require Ash.Query
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
# Helper to create a role with a specific permission set
defp create_role_with_permission_set(permission_set_name) do
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
@ -30,7 +35,7 @@ defmodule Mv.Accounts.UserPoliciesTest do
# Helper to create a user with a specific permission set
# Returns user with role preloaded (required for authorization)
defp create_user_with_permission_set(permission_set_name) do
defp create_user_with_permission_set(permission_set_name, actor) do
# Create role with permission set
role = create_role_with_permission_set(permission_set_name)
@ -41,39 +46,40 @@ defmodule Mv.Accounts.UserPoliciesTest do
email: "user#{System.unique_integer([:positive])}@example.com",
password: "testpassword123"
})
|> Ash.create()
|> Ash.create(actor: actor)
# Assign role to user
{:ok, user} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|> Ash.update()
|> Ash.update(actor: actor)
# Reload user with role preloaded (critical for authorization!)
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts)
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
user_with_role
end
# Helper to create another user (for testing access to other users)
defp create_other_user do
create_user_with_permission_set("own_data")
defp create_other_user(actor) do
create_user_with_permission_set("own_data", actor)
end
# Shared test setup for permission sets with scope :own access
defp setup_user_with_own_access(permission_set) do
user = create_user_with_permission_set(permission_set)
other_user = create_other_user()
defp setup_user_with_own_access(permission_set, actor) do
user = create_user_with_permission_set(permission_set, actor)
other_user = create_other_user(actor)
# Reload user to ensure role is preloaded
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
{:ok, user} =
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
%{user: user, other_user: other_user}
end
describe "own_data permission set (Mitglied)" do
setup do
setup_user_with_own_access("own_data")
setup %{actor: actor} do
setup_user_with_own_access("own_data", actor)
end
test "can read own user record", %{user: user} do
@ -140,8 +146,8 @@ defmodule Mv.Accounts.UserPoliciesTest do
end
describe "read_only permission set (Vorstand/Buchhaltung)" do
setup do
setup_user_with_own_access("read_only")
setup %{actor: actor} do
setup_user_with_own_access("read_only", actor)
end
test "can read own user record", %{user: user} do
@ -208,8 +214,8 @@ defmodule Mv.Accounts.UserPoliciesTest do
end
describe "normal_user permission set (Kassenwart)" do
setup do
setup_user_with_own_access("normal_user")
setup %{actor: actor} do
setup_user_with_own_access("normal_user", actor)
end
test "can read own user record", %{user: user} do
@ -276,12 +282,13 @@ defmodule Mv.Accounts.UserPoliciesTest do
end
describe "admin permission set" do
setup do
user = create_user_with_permission_set("admin")
other_user = create_other_user()
setup %{actor: actor} do
user = create_user_with_permission_set("admin", actor)
other_user = create_other_user(actor)
# Reload user to ensure role is preloaded
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
{:ok, user} =
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
%{user: user, other_user: other_user}
end
@ -335,19 +342,27 @@ defmodule Mv.Accounts.UserPoliciesTest do
describe "AshAuthentication bypass" do
test "register_with_password works without actor" do
# Registration should work without actor (AshAuthentication bypass)
# Note: When directly calling Ash actions in tests, the AshAuthentication bypass
# may not be active, so we use system_actor
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, user} =
Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "register#{System.unique_integer([:positive])}@example.com",
password: "testpassword123"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert user.email
end
test "register_with_rauthy works with OIDC user_info" do
# OIDC registration should work (AshAuthentication bypass)
# Note: When directly calling Ash actions in tests, the AshAuthentication bypass
# may not be active, so we use system_actor
system_actor = Mv.Helpers.SystemActor.get_system_actor()
user_info = %{
"sub" => "oidc_sub_#{System.unique_integer([:positive])}",
"email" => "oidc#{System.unique_integer([:positive])}@example.com"
@ -361,7 +376,7 @@ defmodule Mv.Accounts.UserPoliciesTest do
user_info: user_info,
oauth_tokens: oauth_tokens
})
|> Ash.create()
|> Ash.create(actor: system_actor)
assert user.email
assert user.oidc_id == user_info["sub"]
@ -376,13 +391,15 @@ defmodule Mv.Accounts.UserPoliciesTest do
oauth_tokens = %{access_token: "token", refresh_token: "refresh"}
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, user} =
Accounts.User
|> Ash.Changeset.for_create(:register_with_rauthy, %{
user_info: user_info_create,
oauth_tokens: oauth_tokens
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Now test sign_in_with_rauthy (should work via AshAuthentication bypass)
{:ok, signed_in_user} =
@ -391,7 +408,7 @@ defmodule Mv.Accounts.UserPoliciesTest do
user_info: user_info_create,
oauth_tokens: oauth_tokens
})
|> Ash.read_one()
|> Ash.read_one(actor: system_actor)
assert signed_in_user.id == user.id
end
@ -403,22 +420,4 @@ defmodule Mv.Accounts.UserPoliciesTest do
# when called through the proper authentication flow (sign_in, token refresh, etc.).
# Integration tests that use actual JWT tokens cover this functionality.
end
describe "test environment bypass (NoActor)" do
test "operations without actor are allowed in test environment" do
# In test environment, NoActor check should allow operations
{:ok, user} =
Accounts.User
|> Ash.Changeset.for_create(:create_user, %{
email: "noactor#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create()
assert user.email
# Read should also work
{:ok, fetched_user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts)
assert fetched_user.id == user.id
end
end
end

View file

@ -7,12 +7,17 @@ defmodule Mv.Authorization.ActorTest do
alias Mv.Accounts
alias Mv.Authorization.Actor
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "ensure_loaded/1" do
test "returns nil when actor is nil" do
assert Actor.ensure_loaded(nil) == nil
end
test "returns actor as-is when role is already loaded" do
test "returns actor as-is when role is already loaded", %{actor: actor} do
# Create user with role
{:ok, user} =
Accounts.User
@ -20,7 +25,7 @@ defmodule Mv.Authorization.ActorTest do
email: "test#{System.unique_integer([:positive])}@example.com",
password: "testpassword123"
})
|> Ash.create()
|> Ash.create(actor: actor)
# Load role
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts)
@ -31,7 +36,7 @@ defmodule Mv.Authorization.ActorTest do
assert result.role != %Ash.NotLoaded{}
end
test "loads role when it's NotLoaded" do
test "loads role when it's NotLoaded", %{actor: actor} do
# Create a role first
{:ok, role} =
Mv.Authorization.Role
@ -40,7 +45,7 @@ defmodule Mv.Authorization.ActorTest do
description: "Test role",
permission_set_name: "own_data"
})
|> Ash.create()
|> Ash.create(actor: actor)
# Create user with role
{:ok, user} =
@ -49,18 +54,18 @@ defmodule Mv.Authorization.ActorTest do
email: "test#{System.unique_integer([:positive])}@example.com",
password: "testpassword123"
})
|> Ash.create()
|> Ash.create(actor: actor)
# Assign role to user
{:ok, user_with_role} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|> Ash.update()
|> Ash.update(actor: actor)
# Fetch user again WITHOUT loading role (simulates "role not preloaded" scenario)
{:ok, user_without_role_loaded} =
Ash.get(Accounts.User, user_with_role.id, domain: Mv.Accounts)
Ash.get(Accounts.User, user_with_role.id, domain: Mv.Accounts, actor: actor)
# User has role as NotLoaded (relationship not preloaded)
assert match?(%Ash.NotLoaded{}, user_without_role_loaded.role)

View file

@ -36,7 +36,10 @@ defmodule Mv.Authorization.Checks.HasPermissionFailClosedTest do
|> Ash.Query.new()
|> Ash.Query.filter_input(deny_filter)
{:ok, results} = Ash.read(query, domain: Mv.Membership, authorize?: false)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, results} =
Ash.read(query, domain: Mv.Membership, authorize?: false, actor: system_actor)
# Assert: deny-filter must match nothing
assert results == []

View file

@ -6,6 +6,11 @@ defmodule Mv.Authorization.RoleTest do
alias Mv.Authorization
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "permission_set_name validation" do
test "accepts valid permission set names" do
attrs = %{
@ -42,7 +47,7 @@ defmodule Mv.Authorization.RoleTest do
end
describe "system role deletion protection" do
test "prevents deletion of system roles" do
test "prevents deletion of system roles", %{actor: actor} do
# is_system_role is not settable via public API, so we use Ash.Changeset directly
changeset =
Mv.Authorization.Role
@ -52,7 +57,7 @@ defmodule Mv.Authorization.RoleTest do
})
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
{:ok, system_role} = Ash.create(changeset)
{:ok, system_role} = Ash.create(changeset, actor: actor)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Authorization.destroy_role(system_role)

View file

@ -43,51 +43,55 @@ defmodule Mv.Helpers.SystemActorTest do
# Helper function to ensure system user exists with admin role
defp ensure_system_user(admin_role) do
# Use authorize?: false for bootstrap operations
case Accounts.User
|> Ash.Query.filter(email == ^"system@mila.local")
|> Ash.read_one(domain: Mv.Accounts) do
|> Ash.read_one(domain: Mv.Accounts, authorize?: false) do
{:ok, user} when not is_nil(user) ->
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!()
|> Ash.load!(:role, domain: Mv.Accounts)
|> Ash.update!(authorize?: false)
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
_ ->
Accounts.create_user!(%{email: "system@mila.local"},
upsert?: true,
upsert_identity: :unique_email
upsert_identity: :unique_email,
authorize?: false
)
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!()
|> Ash.load!(:role, domain: Mv.Accounts)
|> Ash.update!(authorize?: false)
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
end
end
# Helper function to ensure admin user exists with admin role
defp ensure_admin_user(admin_role) do
# Use authorize?: false for bootstrap operations
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
case Accounts.User
|> Ash.Query.filter(email == ^admin_email)
|> Ash.read_one(domain: Mv.Accounts) do
|> Ash.read_one(domain: Mv.Accounts, authorize?: false) do
{:ok, user} when not is_nil(user) ->
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!()
|> Ash.load!(:role, domain: Mv.Accounts)
|> Ash.update!(authorize?: false)
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
_ ->
Accounts.create_user!(%{email: admin_email},
upsert?: true,
upsert_identity: :unique_email
upsert_identity: :unique_email,
authorize?: false
)
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!()
|> Ash.load!(:role, domain: Mv.Accounts)
|> Ash.update!(authorize?: false)
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
end
end
@ -114,11 +118,13 @@ defmodule Mv.Helpers.SystemActorTest do
test "falls back to admin user if system user doesn't exist", %{admin_user: _admin_user} do
# Delete system user if it exists
system_actor = SystemActor.get_system_actor()
case Accounts.User
|> Ash.Query.filter(email == ^"system@mila.local")
|> Ash.read_one(domain: Mv.Accounts) do
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
{:ok, user} when not is_nil(user) ->
Ash.destroy!(user, domain: Mv.Accounts)
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
_ ->
:ok
@ -151,11 +157,13 @@ defmodule Mv.Helpers.SystemActorTest do
test "creates system user in test environment if none exists", %{admin_role: _admin_role} do
# In test environment, system actor should auto-create if missing
# Delete all users to test auto-creation
system_actor = SystemActor.get_system_actor()
case Accounts.User
|> Ash.Query.filter(email == ^"system@mila.local")
|> Ash.read_one(domain: Mv.Accounts) do
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
{:ok, user} when not is_nil(user) ->
Ash.destroy!(user, domain: Mv.Accounts)
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
_ ->
:ok
@ -163,11 +171,13 @@ defmodule Mv.Helpers.SystemActorTest do
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
system_actor = SystemActor.get_system_actor()
case Accounts.User
|> Ash.Query.filter(email == ^admin_email)
|> Ash.read_one(domain: Mv.Accounts) do
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
{:ok, user} when not is_nil(user) ->
Ash.destroy!(user, domain: Mv.Accounts)
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
_ ->
:ok
@ -211,11 +221,13 @@ defmodule Mv.Helpers.SystemActorTest do
test "returns error tuple when system actor cannot be loaded" do
# Delete all users to force error
system_actor = SystemActor.get_system_actor()
case Accounts.User
|> Ash.Query.filter(email == ^"system@mila.local")
|> Ash.read_one(domain: Mv.Accounts) do
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
{:ok, user} when not is_nil(user) ->
Ash.destroy!(user, domain: Mv.Accounts)
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
_ ->
:ok
@ -223,11 +235,13 @@ defmodule Mv.Helpers.SystemActorTest do
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
system_actor = SystemActor.get_system_actor()
case Accounts.User
|> Ash.Query.filter(email == ^admin_email)
|> Ash.read_one(domain: Mv.Accounts) do
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
{:ok, user} when not is_nil(user) ->
Ash.destroy!(user, domain: Mv.Accounts)
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
_ ->
:ok
@ -252,18 +266,22 @@ defmodule Mv.Helpers.SystemActorTest do
describe "edge cases" do
test "raises error if admin user has no role", %{admin_user: admin_user} do
system_actor = SystemActor.get_system_actor()
# Remove role from admin user
admin_user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, nil, type: :append_and_remove)
|> Ash.update!()
|> Ash.update!(actor: system_actor)
# Delete system user to force fallback
system_actor = SystemActor.get_system_actor()
case Accounts.User
|> Ash.Query.filter(email == ^"system@mila.local")
|> Ash.read_one(domain: Mv.Accounts) do
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
{:ok, user} when not is_nil(user) ->
Ash.destroy!(user, domain: Mv.Accounts)
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
_ ->
:ok
@ -279,11 +297,13 @@ defmodule Mv.Helpers.SystemActorTest do
test "handles concurrent calls without race conditions" do
# Delete system user and admin user to force creation
system_actor = SystemActor.get_system_actor()
case Accounts.User
|> Ash.Query.filter(email == ^"system@mila.local")
|> Ash.read_one(domain: Mv.Accounts) do
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
{:ok, user} when not is_nil(user) ->
Ash.destroy!(user, domain: Mv.Accounts)
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
_ ->
:ok
@ -291,11 +311,13 @@ defmodule Mv.Helpers.SystemActorTest do
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
system_actor = SystemActor.get_system_actor()
case Accounts.User
|> Ash.Query.filter(email == ^admin_email)
|> Ash.read_one(domain: Mv.Accounts) do
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
{:ok, user} when not is_nil(user) ->
Ash.destroy!(user, domain: Mv.Accounts)
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
_ ->
:ok
@ -330,11 +352,13 @@ defmodule Mv.Helpers.SystemActorTest do
permission_set_name: "read_only"
})
system_actor = SystemActor.get_system_actor()
# Assign wrong role to system user
system_user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, read_only_role, type: :append_and_remove)
|> Ash.update!()
|> Ash.update!(actor: system_actor)
SystemActor.invalidate_cache()
@ -345,11 +369,13 @@ defmodule Mv.Helpers.SystemActorTest do
end
test "raises error if system user has no role", %{system_user: system_user} do
system_actor = SystemActor.get_system_actor()
# Remove role from system user
system_user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, nil, type: :append_and_remove)
|> Ash.update!()
|> Ash.update!(actor: system_actor)
SystemActor.invalidate_cache()

View file

@ -101,7 +101,8 @@ defmodule Mv.Membership.Import.MemberCSVTest do
assert chunk_result.errors == []
# Verify member was created
members = Mv.Membership.list_members!()
system_actor = Mv.Helpers.SystemActor.get_system_actor()
members = Mv.Membership.list_members!(actor: system_actor)
assert Enum.any?(members, &(&1.email == "john@example.com"))
end
@ -174,8 +175,12 @@ defmodule Mv.Membership.Import.MemberCSVTest do
test "returns error for duplicate email" do
# Create existing member first
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, _existing} =
Mv.Membership.create_member(%{email: "duplicate@example.com", first_name: "Existing"})
Mv.Membership.create_member(%{email: "duplicate@example.com", first_name: "Existing"},
actor: system_actor
)
chunk_rows_with_lines = [
{2, %{member: %{email: "duplicate@example.com", first_name: "New"}, custom: %{}}}
@ -199,6 +204,8 @@ defmodule Mv.Membership.Import.MemberCSVTest do
end
test "creates member with custom field values" do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create custom field first
{:ok, custom_field} =
Mv.Membership.CustomField
@ -206,7 +213,7 @@ defmodule Mv.Membership.Import.MemberCSVTest do
name: "Phone",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: system_actor)
chunk_rows_with_lines = [
{2,
@ -232,7 +239,8 @@ defmodule Mv.Membership.Import.MemberCSVTest do
assert chunk_result.failed == 0
# Verify member and custom field value were created
members = Mv.Membership.list_members!()
system_actor = Mv.Helpers.SystemActor.get_system_actor()
members = Mv.Membership.list_members!(actor: system_actor)
member = Enum.find(members, &(&1.email == "withcustom@example.com"))
assert member != nil

View file

@ -16,8 +16,13 @@ defmodule Mv.Membership.MemberPoliciesTest do
require Ash.Query
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
# Helper to create a role with a specific permission set
defp create_role_with_permission_set(permission_set_name) do
defp create_role_with_permission_set(permission_set_name, _actor) do
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
case Authorization.create_role(%{
@ -32,9 +37,9 @@ defmodule Mv.Membership.MemberPoliciesTest do
# Helper to create a user with a specific permission set
# Returns user with role preloaded (required for authorization)
defp create_user_with_permission_set(permission_set_name) do
defp create_user_with_permission_set(permission_set_name, actor) do
# Create role with permission set
role = create_role_with_permission_set(permission_set_name)
role = create_role_with_permission_set(permission_set_name, actor)
# Create user
{:ok, user} =
@ -43,28 +48,28 @@ defmodule Mv.Membership.MemberPoliciesTest do
email: "user#{System.unique_integer([:positive])}@example.com",
password: "testpassword123"
})
|> Ash.create()
|> Ash.create(actor: actor)
# Assign role to user
{:ok, user} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|> Ash.update()
|> Ash.update(actor: actor)
# Reload user with role preloaded (critical for authorization!)
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts)
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
user_with_role
end
# Helper to create an admin user (for creating test fixtures)
defp create_admin_user do
create_user_with_permission_set("admin")
defp create_admin_user(actor) do
create_user_with_permission_set("admin", actor)
end
# Helper to create a member linked to a user
defp create_linked_member_for_user(user) do
admin = create_admin_user()
defp create_linked_member_for_user(user, actor) do
admin = create_admin_user(actor)
# Create member
# NOTE: We need to ensure the member is actually persisted to the database
@ -96,8 +101,8 @@ defmodule Mv.Membership.MemberPoliciesTest do
end
# Helper to create an unlinked member (no user relationship)
defp create_unlinked_member do
admin = create_admin_user()
defp create_unlinked_member(actor) do
admin = create_admin_user(actor)
{:ok, member} =
Membership.Member
@ -112,14 +117,16 @@ defmodule Mv.Membership.MemberPoliciesTest do
end
describe "own_data permission set (Mitglied)" do
setup do
user = create_user_with_permission_set("own_data")
linked_member = create_linked_member_for_user(user)
unlinked_member = create_unlinked_member()
setup %{actor: actor} do
user = create_user_with_permission_set("own_data", actor)
linked_member = create_linked_member_for_user(user, actor)
unlinked_member = create_unlinked_member(actor)
# Reload user to get updated member_id
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts)
{:ok, user} =
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts, actor: actor)
%{user: user, linked_member: linked_member, unlinked_member: unlinked_member}
end
@ -165,7 +172,10 @@ defmodule Mv.Membership.MemberPoliciesTest do
end
end
test "list members returns only linked member", %{user: user, linked_member: linked_member} do
test "list members returns only linked member", %{
user: user,
linked_member: linked_member
} do
{:ok, members} = Ash.read(Membership.Member, actor: user, domain: Mv.Membership)
# Should only return the linked member (scope :linked filters)
@ -185,7 +195,10 @@ defmodule Mv.Membership.MemberPoliciesTest do
end
end
test "cannot destroy member (returns forbidden)", %{user: user, linked_member: linked_member} do
test "cannot destroy member (returns forbidden)", %{
user: user,
linked_member: linked_member
} do
assert_raise Ash.Error.Forbidden, fn ->
Ash.destroy!(linked_member, actor: user)
end
@ -193,13 +206,14 @@ defmodule Mv.Membership.MemberPoliciesTest do
end
describe "read_only permission set (Vorstand/Buchhaltung)" do
setup do
user = create_user_with_permission_set("read_only")
linked_member = create_linked_member_for_user(user)
unlinked_member = create_unlinked_member()
setup %{actor: actor} do
user = create_user_with_permission_set("read_only", actor)
linked_member = create_linked_member_for_user(user, actor)
unlinked_member = create_unlinked_member(actor)
# Reload user to get updated member_id
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
{:ok, user} =
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
%{user: user, linked_member: linked_member, unlinked_member: unlinked_member}
end
@ -217,7 +231,10 @@ defmodule Mv.Membership.MemberPoliciesTest do
assert unlinked_member.id in member_ids
end
test "can read individual member", %{user: user, unlinked_member: unlinked_member} do
test "can read individual member", %{
user: user,
unlinked_member: unlinked_member
} do
{:ok, member} =
Ash.get(Membership.Member, unlinked_member.id, actor: user, domain: Mv.Membership)
@ -258,13 +275,14 @@ defmodule Mv.Membership.MemberPoliciesTest do
end
describe "normal_user permission set (Kassenwart)" do
setup do
user = create_user_with_permission_set("normal_user")
linked_member = create_linked_member_for_user(user)
unlinked_member = create_unlinked_member()
setup %{actor: actor} do
user = create_user_with_permission_set("normal_user", actor)
linked_member = create_linked_member_for_user(user, actor)
unlinked_member = create_unlinked_member(actor)
# Reload user to get updated member_id
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
{:ok, user} =
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
%{user: user, linked_member: linked_member, unlinked_member: unlinked_member}
end
@ -315,13 +333,14 @@ defmodule Mv.Membership.MemberPoliciesTest do
end
describe "admin permission set" do
setup do
user = create_user_with_permission_set("admin")
linked_member = create_linked_member_for_user(user)
unlinked_member = create_unlinked_member()
setup %{actor: actor} do
user = create_user_with_permission_set("admin", actor)
linked_member = create_linked_member_for_user(user, actor)
unlinked_member = create_unlinked_member(actor)
# Reload user to get updated member_id
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
{:ok, user} =
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
%{user: user, linked_member: linked_member, unlinked_member: unlinked_member}
end
@ -361,7 +380,10 @@ defmodule Mv.Membership.MemberPoliciesTest do
assert updated_member.first_name == "Updated"
end
test "can destroy any member", %{user: user, unlinked_member: unlinked_member} do
test "can destroy any member", %{
user: user,
unlinked_member: unlinked_member
} do
:ok = Ash.destroy(unlinked_member, actor: user)
# Verify member is deleted
@ -370,19 +392,24 @@ defmodule Mv.Membership.MemberPoliciesTest do
end
describe "special case: user can always READ linked member" do
# Note: The special case policy only applies to :read actions.
# Updates are handled by HasPermission with :linked scope (if permission exists).
setup %{actor: _actor} do
# Note: The special case policy only applies to :read actions.
# Updates are handled by HasPermission with :linked scope (if permission exists).
:ok
end
test "read_only user can read linked member (via special case bypass)" do
test "read_only user can read linked member (via special case bypass)", %{actor: actor} do
# read_only has Member.read scope :all, but the special case ensures
# users can ALWAYS read their linked member, even if they had no read permission.
# This test verifies the special case works independently of permission sets.
user = create_user_with_permission_set("read_only")
linked_member = create_linked_member_for_user(user)
user = create_user_with_permission_set("read_only", actor)
linked_member = create_linked_member_for_user(user, actor)
# Reload user to get updated member_id
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts)
{:ok, user} =
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts, actor: actor)
# Should succeed (special case bypass policy for :read takes precedence)
{:ok, member} =
@ -391,15 +418,17 @@ defmodule Mv.Membership.MemberPoliciesTest do
assert member.id == linked_member.id
end
test "own_data user can read linked member (via special case bypass)" do
test "own_data user can read linked member (via special case bypass)", %{actor: actor} do
# own_data has Member.read scope :linked, but the special case ensures
# users can ALWAYS read their linked member regardless of permission set.
user = create_user_with_permission_set("own_data")
linked_member = create_linked_member_for_user(user)
user = create_user_with_permission_set("own_data", actor)
linked_member = create_linked_member_for_user(user, actor)
# Reload user to get updated member_id
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts)
{:ok, user} =
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts, actor: actor)
# Should succeed (special case bypass policy for :read takes precedence)
{:ok, member} =
@ -408,15 +437,19 @@ defmodule Mv.Membership.MemberPoliciesTest do
assert member.id == linked_member.id
end
test "own_data user can update linked member (via HasPermission :linked scope)" do
test "own_data user can update linked member (via HasPermission :linked scope)", %{
actor: actor
} do
# Update is NOT handled by special case - it's handled by HasPermission
# with :linked scope. own_data has Member.update scope :linked.
user = create_user_with_permission_set("own_data")
linked_member = create_linked_member_for_user(user)
user = create_user_with_permission_set("own_data", actor)
linked_member = create_linked_member_for_user(user, actor)
# Reload user to get updated member_id
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts)
{:ok, user} =
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts, actor: actor)
# Should succeed via HasPermission check (not special case)
{:ok, updated_member} =

View file

@ -19,8 +19,13 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
require Ash.Query
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
# Helper to create a membership fee type
defp create_fee_type(attrs) do
defp create_fee_type(attrs, actor) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
@ -31,12 +36,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
|> Ash.create!(actor: actor)
end
# Helper to create a member. Note: If membership_fee_type_id is provided,
# cycles will be auto-generated during creation in test environment.
defp create_member(attrs) do
defp create_member(attrs, actor) do
default_attrs = %{
first_name: "Test",
last_name: "User",
@ -47,7 +52,7 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
|> Ash.create!(actor: actor)
end
# Helper to create a member and explicitly generate cycles with a fixed "today" date.
@ -56,7 +61,7 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
# Note: We first create the member without fee_type_id, then assign it via update,
# which triggers the after_action hook. However, we then explicitly regenerate
# cycles with the fixed "today" date to ensure consistency.
defp create_member_with_cycles(attrs, today) do
defp create_member_with_cycles(attrs, today, actor) do
# Extract membership_fee_type_id if present
fee_type_id = Map.get(attrs, :membership_fee_type_id)
@ -64,14 +69,14 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
attrs_without_fee_type = Map.delete(attrs, :membership_fee_type_id)
member =
create_member(attrs_without_fee_type)
create_member(attrs_without_fee_type, actor)
# Assign fee type if provided (this will trigger auto-generation with real today)
member =
if fee_type_id do
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type_id})
|> Ash.update!()
|> Ash.update!(actor: actor)
else
member
end
@ -80,8 +85,8 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
# This ensures the test uses the fixed date, not the real current date
if fee_type_id && member.join_date do
# Delete any existing cycles first to ensure clean state
existing_cycles = get_member_cycles(member.id)
Enum.each(existing_cycles, &Ash.destroy!(&1))
existing_cycles = get_member_cycles(member.id, actor)
Enum.each(existing_cycles, &Ash.destroy!(&1, actor: actor))
# Generate cycles with fixed "today" date
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
@ -91,85 +96,91 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
end
# Helper to get cycles for a member
defp get_member_cycles(member_id) do
defp get_member_cycles(member_id, actor) do
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member_id)
|> Ash.Query.sort(cycle_start: :asc)
|> Ash.read!()
|> Ash.read!(actor: actor)
end
# Helper to set up settings
defp setup_settings(include_joining_cycle) do
defp setup_settings(include_joining_cycle, actor) do
{:ok, settings} = Mv.Membership.get_settings()
settings
|> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle})
|> Ash.update!()
|> Ash.update!(actor: actor)
end
describe "member joins today" do
test "current cycle is generated (yearly)" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
test "current cycle is generated (yearly)", %{actor: actor} do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :yearly}, actor)
today = ~D[2024-06-15]
# Create member WITHOUT fee type first to avoid auto-generation with real today
member =
create_member(%{
join_date: today,
membership_fee_start_date: ~D[2024-01-01]
})
create_member(
%{
join_date: today,
membership_fee_start_date: ~D[2024-01-01]
},
actor
)
# Assign fee type
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
|> Ash.update!(actor: actor)
# Explicitly generate cycles with fixed "today" date
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
# Check all cycles
cycles = get_member_cycles(member.id)
cycles = get_member_cycles(member.id, actor)
# Should have the current year's cycle
cycle_years = Enum.map(cycles, & &1.cycle_start.year)
assert 2024 in cycle_years
end
test "current cycle is generated (monthly)" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :monthly})
test "current cycle is generated (monthly)", %{actor: actor} do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :monthly}, actor)
today = ~D[2024-06-15]
# Create member WITHOUT fee type first to avoid auto-generation with real today
member =
create_member(%{
join_date: today,
membership_fee_start_date: ~D[2024-06-01]
})
create_member(
%{
join_date: today,
membership_fee_start_date: ~D[2024-06-01]
},
actor
)
# Assign fee type
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
|> Ash.update!(actor: actor)
# Explicitly generate cycles with fixed "today" date
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
# Check all cycles
cycles = get_member_cycles(member.id)
cycles = get_member_cycles(member.id, actor)
# Should have June 2024 cycle
assert Enum.any?(cycles, fn c -> c.cycle_start == ~D[2024-06-01] end)
end
test "current cycle is generated (quarterly)" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :quarterly})
test "current cycle is generated (quarterly)", %{actor: actor} do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :quarterly}, actor)
today = ~D[2024-05-15]
@ -181,11 +192,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2024-04-01]
},
today
today,
actor
)
# Check all cycles
cycles = get_member_cycles(member.id)
cycles = get_member_cycles(member.id, actor)
# Should have Q2 2024 cycle
assert Enum.any?(cycles, fn c -> c.cycle_start == ~D[2024-04-01] end)
@ -193,9 +205,9 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
end
describe "member left yesterday" do
test "no future cycles are generated" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
test "no future cycles are generated", %{actor: actor} do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :yearly}, actor)
today = ~D[2024-06-15]
yesterday = Date.add(today, -1)
@ -209,11 +221,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2022-01-01]
},
today
today,
actor
)
# Check all cycles
cycles = get_member_cycles(member.id)
cycles = get_member_cycles(member.id, actor)
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
# 2024 should be included because the member was still active during that cycle
@ -225,21 +238,24 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
refute 2025 in cycle_years
end
test "exit during first month of year stops at that year (monthly)" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :monthly})
test "exit during first month of year stops at that year (monthly)", %{actor: actor} do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :monthly}, actor)
# Create member - cycles will be auto-generated
member =
create_member(%{
join_date: ~D[2024-01-15],
exit_date: ~D[2024-03-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2024-01-01]
})
create_member(
%{
join_date: ~D[2024-01-15],
exit_date: ~D[2024-03-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2024-01-01]
},
actor
)
# Check all cycles
cycles = get_member_cycles(member.id)
cycles = get_member_cycles(member.id, actor)
cycle_months = Enum.map(cycles, & &1.cycle_start.month) |> Enum.sort()
assert 1 in cycle_months
@ -253,18 +269,21 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
end
describe "member has no cycles initially" do
test "returns error when fee type is not assigned" do
setup_settings(true)
test "returns error when fee type is not assigned", %{actor: actor} do
setup_settings(true, actor)
# Create member WITHOUT fee type (no auto-generation)
member =
create_member(%{
join_date: ~D[2022-03-15],
membership_fee_start_date: ~D[2022-01-01]
})
create_member(
%{
join_date: ~D[2022-03-15],
membership_fee_start_date: ~D[2022-01-01]
},
actor
)
# Verify no cycles exist initially
initial_cycles = get_member_cycles(member.id)
initial_cycles = get_member_cycles(member.id, actor)
assert initial_cycles == []
# Trying to generate cycles without fee type should return error
@ -272,9 +291,9 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
assert result == {:error, :no_membership_fee_type}
end
test "generates all cycles when member is created with fee type" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
test "generates all cycles when member is created with fee type", %{actor: actor} do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :yearly}, actor)
today = ~D[2024-06-15]
@ -286,11 +305,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2022-01-01]
},
today
today,
actor
)
# Check all cycles
cycles = get_member_cycles(member.id)
cycles = get_member_cycles(member.id, actor)
# Should have generated all cycles from 2022 to 2024 (3 cycles)
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
@ -303,16 +323,19 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
end
describe "member has existing cycles" do
test "generates from last cycle (not duplicating existing)" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
test "generates from last cycle (not duplicating existing)", %{actor: actor} do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :yearly}, actor)
# Create member WITHOUT fee type first
member =
create_member(%{
join_date: ~D[2022-03-15],
membership_fee_start_date: ~D[2022-01-01]
})
create_member(
%{
join_date: ~D[2022-03-15],
membership_fee_start_date: ~D[2022-01-01]
},
actor
)
# Manually create an existing cycle for 2022
MembershipFeeCycle
@ -323,20 +346,20 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
amount: fee_type.amount,
status: :paid
})
|> Ash.create!()
|> Ash.create!(actor: actor)
# Now assign fee type
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
|> Ash.update!(actor: actor)
# Explicitly generate cycles with fixed "today" date
today = ~D[2024-06-15]
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
# Check all cycles
all_cycles = get_member_cycles(member.id)
all_cycles = get_member_cycles(member.id, actor)
all_cycle_years = Enum.map(all_cycles, & &1.cycle_start.year) |> Enum.sort() |> Enum.uniq()
# Should have 2022 (manually created), 2023 and 2024 (auto-generated)
@ -350,9 +373,9 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
end
describe "year boundary handling" do
test "cycles span across year boundaries correctly (yearly)" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
test "cycles span across year boundaries correctly (yearly)", %{actor: actor} do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :yearly}, actor)
today = ~D[2024-06-15]
@ -364,11 +387,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2023-01-01]
},
today
today,
actor
)
# Check all cycles
cycles = get_member_cycles(member.id)
cycles = get_member_cycles(member.id, actor)
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
# Should have 2023 and 2024
@ -376,9 +400,9 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
assert 2024 in cycle_years
end
test "cycles span across year boundaries correctly (quarterly)" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :quarterly})
test "cycles span across year boundaries correctly (quarterly)", %{actor: actor} do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :quarterly}, actor)
today = ~D[2024-12-15]
@ -390,20 +414,21 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2024-10-01]
},
today
today,
actor
)
# Check all cycles
cycles = get_member_cycles(member.id)
cycles = get_member_cycles(member.id, actor)
cycle_starts = Enum.map(cycles, & &1.cycle_start) |> Enum.sort(Date)
# Should have Q4 2024
assert ~D[2024-10-01] in cycle_starts
end
test "December to January transition (monthly)" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :monthly})
test "December to January transition (monthly)", %{actor: actor} do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :monthly}, actor)
today = ~D[2024-12-31]
@ -415,11 +440,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2024-12-01]
},
today
today,
actor
)
# Check all cycles
cycles = get_member_cycles(member.id)
cycles = get_member_cycles(member.id, actor)
cycle_starts = Enum.map(cycles, & &1.cycle_start) |> Enum.sort(Date)
# Should have Dec 2024
@ -428,9 +454,9 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
end
describe "leap year handling" do
test "February cycles in leap year" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :monthly})
test "February cycles in leap year", %{actor: actor} do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :monthly}, actor)
today = ~D[2024-03-15]
@ -443,11 +469,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2024-02-01]
},
today
today,
actor
)
# Check all cycles
cycles = get_member_cycles(member.id)
cycles = get_member_cycles(member.id, actor)
# Should have February 2024 cycle
feb_cycle = Enum.find(cycles, fn c -> c.cycle_start == ~D[2024-02-01] end)
@ -455,9 +482,9 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
assert feb_cycle != nil
end
test "February cycles in non-leap year" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :monthly})
test "February cycles in non-leap year", %{actor: actor} do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :monthly}, actor)
today = ~D[2023-03-15]
@ -470,11 +497,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2023-02-01]
},
today
today,
actor
)
# Check all cycles
cycles = get_member_cycles(member.id)
cycles = get_member_cycles(member.id, actor)
# Should have February 2023 cycle
feb_cycle = Enum.find(cycles, fn c -> c.cycle_start == ~D[2023-02-01] end)
@ -482,9 +510,9 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
assert feb_cycle != nil
end
test "yearly cycle in leap year" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
test "yearly cycle in leap year", %{actor: actor} do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :yearly}, actor)
today = ~D[2024-12-31]
@ -496,11 +524,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2024-01-01]
},
today
today,
actor
)
# Check all cycles
cycles = get_member_cycles(member.id)
cycles = get_member_cycles(member.id, actor)
# Should have 2024 cycle
cycle_2024 = Enum.find(cycles, fn c -> c.cycle_start == ~D[2024-01-01] end)
@ -510,9 +539,9 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
end
describe "include_joining_cycle variations" do
test "include_joining_cycle = true starts from joining cycle" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
test "include_joining_cycle = true starts from joining cycle", %{actor: actor} do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :yearly}, actor)
today = ~D[2024-06-15]
@ -525,20 +554,21 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
membership_fee_type_id: fee_type.id
# membership_fee_start_date will be auto-calculated
},
today
today,
actor
)
# Check all cycles
cycles = get_member_cycles(member.id)
cycles = get_member_cycles(member.id, actor)
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
# Should include 2023 (joining year)
assert 2023 in cycle_years
end
test "include_joining_cycle = false starts from next cycle" do
setup_settings(false)
fee_type = create_fee_type(%{interval: :yearly})
test "include_joining_cycle = false starts from next cycle", %{actor: actor} do
setup_settings(false, actor)
fee_type = create_fee_type(%{interval: :yearly}, actor)
today = ~D[2024-06-15]
@ -551,11 +581,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
membership_fee_type_id: fee_type.id
# membership_fee_start_date will be auto-calculated
},
today
today,
actor
)
# Check all cycles
cycles = get_member_cycles(member.id)
cycles = get_member_cycles(member.id, actor)
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
# Should NOT include 2023 (joining year)
@ -567,17 +598,22 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
end
describe "inactive member processing" do
test "inactive members receive cycles up to exit_date via generate_cycles_for_all_members" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
test "inactive members receive cycles up to exit_date via generate_cycles_for_all_members", %{
actor: actor
} do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :yearly}, actor)
# Create an inactive member (left in 2023) WITHOUT fee type initially
# This simulates a member that was created before the fee system existed
member =
create_member(%{
join_date: ~D[2021-03-15],
exit_date: ~D[2023-06-15]
})
create_member(
%{
join_date: ~D[2021-03-15],
exit_date: ~D[2023-06-15]
},
actor
)
# Now assign fee type (simulating a retroactive assignment)
member =
@ -586,7 +622,7 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2021-01-01]
})
|> Ash.update!()
|> Ash.update!(actor: actor)
# Run batch generation with a "today" date after the member left
today = ~D[2024-06-15]
@ -596,7 +632,7 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
assert results.total >= 1
# Check the member's cycles
cycles = get_member_cycles(member.id)
cycles = get_member_cycles(member.id, actor)
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort() |> Enum.uniq()
# Should have 2021, 2022, 2023 (exit year included)
@ -608,9 +644,9 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
refute 2024 in cycle_years
end
test "exit_date on cycle_start still generates that cycle" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
test "exit_date on cycle_start still generates that cycle", %{actor: actor} do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :yearly}, actor)
today = ~D[2024-12-31]
@ -624,11 +660,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2022-01-01]
},
today
today,
actor
)
# Check cycles
cycles = get_member_cycles(member.id)
cycles = get_member_cycles(member.id, actor)
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
# 2024 should be included because exit_date == cycle_start means

View file

@ -11,8 +11,13 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
require Ash.Query
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
# Helper to create a membership fee type
defp create_fee_type(attrs) do
defp create_fee_type(attrs, actor) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
@ -23,11 +28,11 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
|> Ash.create!(actor: actor)
end
# Helper to create a member without triggering cycle generation
defp create_member_without_cycles(attrs) do
defp create_member_without_cycles(attrs, actor) do
default_attrs = %{
first_name: "Test",
last_name: "User",
@ -38,50 +43,53 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
|> Ash.create!(actor: actor)
end
# Helper to set up settings with specific include_joining_cycle value
defp setup_settings(include_joining_cycle) do
defp setup_settings(include_joining_cycle, actor) do
{:ok, settings} = Mv.Membership.get_settings()
settings
|> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle})
|> Ash.update!()
|> Ash.update!(actor: actor)
end
# Helper to get cycles for a member
defp get_member_cycles(member_id) do
defp get_member_cycles(member_id, actor) do
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member_id)
|> Ash.Query.sort(cycle_start: :asc)
|> Ash.read!()
|> Ash.read!(actor: actor)
end
describe "generate_cycles_for_member/2" do
test "generates cycles from start date to today" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
test "generates cycles from start date to today", %{actor: actor} do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :yearly}, actor)
# Create member WITHOUT fee type first to avoid auto-generation
member =
create_member_without_cycles(%{
join_date: ~D[2022-03-15],
membership_fee_start_date: ~D[2022-01-01]
})
create_member_without_cycles(
%{
join_date: ~D[2022-03-15],
membership_fee_start_date: ~D[2022-01-01]
},
actor
)
# Assign fee type
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
|> Ash.update!(actor: actor)
# Explicitly generate cycles with fixed "today" date to avoid date dependency
today = ~D[2024-06-15]
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
# Verify cycles were generated
all_cycles = get_member_cycles(member.id)
all_cycles = get_member_cycles(member.id, actor)
cycle_years = Enum.map(all_cycles, & &1.cycle_start.year) |> Enum.sort() |> Enum.uniq()
# With include_joining_cycle=true and join_date=2022-03-15,
@ -92,16 +100,19 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
assert 2024 in cycle_years
end
test "generates cycles from last existing cycle" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
test "generates cycles from last existing cycle", %{actor: actor} do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :yearly}, actor)
# Create member without fee type first to avoid auto-generation
member =
create_member_without_cycles(%{
join_date: ~D[2022-03-15],
membership_fee_start_date: ~D[2022-01-01]
})
create_member_without_cycles(
%{
join_date: ~D[2022-03-15],
membership_fee_start_date: ~D[2022-01-01]
},
actor
)
# Manually create a cycle for 2022
MembershipFeeCycle
@ -112,13 +123,13 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
amount: fee_type.amount,
status: :paid
})
|> Ash.create!()
|> Ash.create!(actor: actor)
# Now assign fee type to member
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
|> Ash.update!(actor: actor)
# Generate cycles with specific "today" date
today = ~D[2024-06-15]
@ -130,17 +141,20 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
assert 2022 not in new_cycle_years
end
test "respects left_at boundary (stops generation)" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
test "respects left_at boundary (stops generation)", %{actor: actor} do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :yearly}, actor)
member =
create_member_without_cycles(%{
join_date: ~D[2022-03-15],
exit_date: ~D[2023-06-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2022-01-01]
})
create_member_without_cycles(
%{
join_date: ~D[2022-03-15],
exit_date: ~D[2023-06-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2022-01-01]
},
actor
)
# Generate cycles with specific "today" date far in the future
today = ~D[2025-06-15]
@ -154,16 +168,19 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
assert 2025 not in cycle_years
end
test "skips existing cycles (idempotent)" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
test "skips existing cycles (idempotent)", %{actor: actor} do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :yearly}, actor)
member =
create_member_without_cycles(%{
join_date: ~D[2023-03-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2023-01-01]
})
create_member_without_cycles(
%{
join_date: ~D[2023-03-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2023-01-01]
},
actor
)
today = ~D[2024-06-15]
@ -177,37 +194,43 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
assert second_cycles == []
end
test "does not fill gaps when cycles were deleted" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
test "does not fill gaps when cycles were deleted", %{actor: actor} do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :yearly}, actor)
# Create member without fee type first to control which cycles exist
member =
create_member_without_cycles(%{
join_date: ~D[2020-03-15],
membership_fee_start_date: ~D[2020-01-01]
})
create_member_without_cycles(
%{
join_date: ~D[2020-03-15],
membership_fee_start_date: ~D[2020-01-01]
},
actor
)
# Manually create cycles for 2020, 2021, 2022, 2023
for year <- [2020, 2021, 2022, 2023] do
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, %{
cycle_start: Date.new!(year, 1, 1),
member_id: member.id,
membership_fee_type_id: fee_type.id,
amount: fee_type.amount,
status: :unpaid
})
|> Ash.create!()
|> Ash.Changeset.for_create(
:create,
%{
cycle_start: Date.new!(year, 1, 1),
member_id: member.id,
membership_fee_type_id: fee_type.id,
amount: fee_type.amount,
status: :unpaid
}
)
|> Ash.create!(actor: actor)
end
# Delete the 2021 cycle (create a gap)
cycle_2021 =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^~D[2021-01-01])
|> Ash.read_one!()
|> Ash.read_one!(actor: actor)
Ash.destroy!(cycle_2021)
Ash.destroy!(cycle_2021, actor: actor)
# Now assign fee type to member (this triggers generation)
# Since cycles already exist (2020, 2022, 2023), the generator will
@ -215,10 +238,10 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
|> Ash.update!(actor: actor)
# Verify gap was NOT filled and new cycles were generated from last existing
all_cycles = get_member_cycles(member.id)
all_cycles = get_member_cycles(member.id, actor)
all_cycle_years = Enum.map(all_cycles, & &1.cycle_start.year) |> Enum.sort()
# 2021 should NOT exist (gap was not filled)
@ -234,20 +257,23 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
assert 2025 in all_cycle_years
end
test "sets correct amount from membership fee type" do
setup_settings(true)
test "sets correct amount from membership fee type", %{actor: actor} do
setup_settings(true, actor)
amount = Decimal.new("75.50")
fee_type = create_fee_type(%{interval: :yearly, amount: amount})
fee_type = create_fee_type(%{interval: :yearly, amount: amount}, actor)
member =
create_member_without_cycles(%{
join_date: ~D[2024-03-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2024-01-01]
})
create_member_without_cycles(
%{
join_date: ~D[2024-03-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2024-01-01]
},
actor
)
# Verify cycles were generated with correct amount
all_cycles = get_member_cycles(member.id)
all_cycles = get_member_cycles(member.id, actor)
refute Enum.empty?(all_cycles), "Expected cycles to be generated"
# All cycles should have the correct amount
@ -256,21 +282,24 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
end)
end
test "handles NULL membership_fee_start_date by calculating from join_date" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :quarterly})
test "handles NULL membership_fee_start_date by calculating from join_date", %{actor: actor} do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :quarterly}, actor)
# Create member without membership_fee_start_date - it will be auto-calculated
# and cycles will be auto-generated
member =
create_member_without_cycles(%{
join_date: ~D[2024-02-15],
membership_fee_type_id: fee_type.id
# No membership_fee_start_date - should be calculated
})
create_member_without_cycles(
%{
join_date: ~D[2024-02-15],
membership_fee_type_id: fee_type.id
# No membership_fee_start_date - should be calculated
},
actor
)
# Verify cycles were auto-generated
all_cycles = get_member_cycles(member.id)
all_cycles = get_member_cycles(member.id, actor)
# With include_joining_cycle=true and join_date=2024-02-15 (quarterly),
# start_date should be 2024-01-01 (Q1 start)
@ -284,28 +313,34 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
assert first_cycle_start == ~D[2024-01-01]
end
test "returns error when member has no membership_fee_type" do
test "returns error when member has no membership_fee_type", %{actor: actor} do
# Create member without fee type - no auto-generation will occur
member =
create_member_without_cycles(%{
join_date: ~D[2024-03-15]
# No membership_fee_type_id
})
create_member_without_cycles(
%{
join_date: ~D[2024-03-15]
# No membership_fee_type_id
},
actor
)
{:error, reason} = CycleGenerator.generate_cycles_for_member(member.id)
assert reason == :no_membership_fee_type
end
test "returns error when member has no join_date" do
fee_type = create_fee_type(%{interval: :yearly})
test "returns error when member has no join_date", %{actor: actor} do
fee_type = create_fee_type(%{interval: :yearly}, actor)
# Create member without join_date - no auto-generation will occur
# (after_action hook checks for join_date)
member =
create_member_without_cycles(%{
membership_fee_type_id: fee_type.id
# No join_date
})
create_member_without_cycles(
%{
membership_fee_type_id: fee_type.id
# No join_date
},
actor
)
{:error, reason} = CycleGenerator.generate_cycles_for_member(member.id)
assert reason == :no_join_date
@ -357,24 +392,30 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
end
describe "generate_cycles_for_all_members/1" do
test "generates cycles for multiple members" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
test "generates cycles for multiple members", %{actor: actor} do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :yearly}, actor)
# Create multiple members
_member1 =
create_member_without_cycles(%{
join_date: ~D[2024-01-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2024-01-01]
})
create_member_without_cycles(
%{
join_date: ~D[2024-01-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2024-01-01]
},
actor
)
_member2 =
create_member_without_cycles(%{
join_date: ~D[2024-02-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2024-01-01]
})
create_member_without_cycles(
%{
join_date: ~D[2024-02-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2024-01-01]
},
actor
)
today = ~D[2024-06-15]
{:ok, results} = CycleGenerator.generate_cycles_for_all_members(today: today)
@ -387,16 +428,19 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
end
describe "lock mechanism" do
test "prevents concurrent generation for same member" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
test "prevents concurrent generation for same member", %{actor: actor} do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :yearly}, actor)
member =
create_member_without_cycles(%{
join_date: ~D[2022-03-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2022-01-01]
})
create_member_without_cycles(
%{
join_date: ~D[2022-03-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2022-01-01]
},
actor
)
today = ~D[2024-06-15]

View file

@ -8,8 +8,13 @@ defmodule MvWeb.OidcE2EFlowTest do
use MvWeb.ConnCase, async: true
require Ash.Query
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "E2E: New OIDC user registration" do
test "new user can register via OIDC", %{conn: _conn} do
test "new user can register via OIDC", %{conn: _conn, actor: actor} do
# Simulate OIDC callback for brand new user
user_info = %{
"sub" => "new_oidc_user_123",
@ -18,10 +23,13 @@ defmodule MvWeb.OidcE2EFlowTest do
# Call register action
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
assert {:ok, new_user} = result
assert to_string(new_user.email) == "newuser@example.com"
@ -30,17 +38,20 @@ defmodule MvWeb.OidcE2EFlowTest do
# Verify user can be found by oidc_id
{:ok, [found_user]} =
Mv.Accounts.read_sign_in_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.read_sign_in_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
assert found_user.id == new_user.id
end
end
describe "E2E: Existing OIDC user sign-in" do
test "existing OIDC user can sign in and email updates", %{conn: _conn} do
test "existing OIDC user can sign in and email updates", %{conn: _conn, actor: actor} do
# Create OIDC user
user =
create_test_user(%{
@ -56,10 +67,13 @@ defmodule MvWeb.OidcE2EFlowTest do
# Register (upsert) with new email
{:ok, updated_user} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: updated_user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: updated_user_info,
oauth_tokens: %{}
},
actor: actor
)
# Same user, updated email
assert updated_user.id == user.id
@ -70,7 +84,7 @@ defmodule MvWeb.OidcE2EFlowTest do
describe "E2E: OIDC with existing password account (Email Collision)" do
test "OIDC registration with password account email triggers PasswordVerificationRequired",
%{conn: _conn} do
%{conn: _conn, actor: actor} do
# Step 1: Create a password-only user
password_user =
create_test_user(%{
@ -86,10 +100,13 @@ defmodule MvWeb.OidcE2EFlowTest do
}
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
# Step 3: Should fail with PasswordVerificationRequired
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
@ -106,7 +123,7 @@ defmodule MvWeb.OidcE2EFlowTest do
end
test "full E2E flow: OIDC collision -> password verification -> account linked",
%{conn: _conn} do
%{conn: _conn, actor: actor} do
# Step 1: Create password user
password_user =
create_test_user(%{
@ -122,10 +139,13 @@ defmodule MvWeb.OidcE2EFlowTest do
}
{:error, %Ash.Error.Invalid{errors: errors}} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
# Extract the error
password_error =
@ -142,12 +162,12 @@ defmodule MvWeb.OidcE2EFlowTest do
{:ok, linked_user} =
Mv.Accounts.User
|> Ash.Query.filter(id == ^password_user.id)
|> Ash.read_one!()
|> Ash.read_one!(actor: actor)
|> Ash.Changeset.for_update(:link_oidc_id, %{
oidc_id: user_info["sub"],
oidc_user_info: user_info
})
|> Ash.update()
|> Ash.update(actor: actor)
# Verify account is now linked
assert linked_user.id == password_user.id
@ -158,17 +178,20 @@ defmodule MvWeb.OidcE2EFlowTest do
# Step 5: User can now sign in via OIDC
{:ok, [signed_in_user]} =
Mv.Accounts.read_sign_in_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.read_sign_in_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
assert signed_in_user.id == password_user.id
assert signed_in_user.oidc_id == "oidc_link_888"
end
test "E2E: OIDC collision with different email at provider updates email after linking",
%{conn: _conn} do
%{conn: _conn, actor: actor} do
# Password user with old email
password_user =
create_test_user(%{
@ -199,12 +222,12 @@ defmodule MvWeb.OidcE2EFlowTest do
{:ok, linked_user} =
Mv.Accounts.User
|> Ash.Query.filter(id == ^password_user.id)
|> Ash.read_one!()
|> Ash.read_one!(actor: actor)
|> Ash.Changeset.for_update(:link_oidc_id, %{
oidc_id: updated_user_info["sub"],
oidc_user_info: updated_user_info
})
|> Ash.update()
|> Ash.update(actor: actor)
# Email should be updated to match OIDC provider
assert to_string(linked_user.email) == "new@example.com"
@ -213,7 +236,10 @@ defmodule MvWeb.OidcE2EFlowTest do
end
describe "E2E: OIDC with linked member" do
test "E2E: email sync to member when linking OIDC to password account", %{conn: _conn} do
test "E2E: email sync to member when linking OIDC to password account", %{
conn: _conn,
actor: actor
} do
# Create member
member =
Ash.Seed.seed!(Mv.Membership.Member, %{
@ -239,10 +265,13 @@ defmodule MvWeb.OidcE2EFlowTest do
# Collision detected
{:error, %Ash.Error.Invalid{}} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
# After password verification, link OIDC with NEW email
updated_user_info = %{
@ -253,24 +282,27 @@ defmodule MvWeb.OidcE2EFlowTest do
{:ok, linked_user} =
Mv.Accounts.User
|> Ash.Query.filter(id == ^password_user.id)
|> Ash.read_one!()
|> Ash.read_one!(actor: actor)
|> Ash.Changeset.for_update(:link_oidc_id, %{
oidc_id: updated_user_info["sub"],
oidc_user_info: updated_user_info
})
|> Ash.update()
|> Ash.update(actor: actor)
# User email updated
assert to_string(linked_user.email) == "newmember@example.com"
# Member email should be synced
{:ok, updated_member} = Ash.get(Mv.Membership.Member, member.id)
{:ok, updated_member} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
assert to_string(updated_member.email) == "newmember@example.com"
end
end
describe "E2E: Security scenarios" do
test "E2E: password-only user cannot be accessed via OIDC without password", %{conn: _conn} do
test "E2E: password-only user cannot be accessed via OIDC without password", %{
conn: _conn,
actor: actor
} do
# Create password user
_password_user =
create_test_user(%{
@ -287,10 +319,13 @@ defmodule MvWeb.OidcE2EFlowTest do
# Sign-in should fail (no matching oidc_id)
result =
Mv.Accounts.read_sign_in_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.read_sign_in_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
case result do
{:ok, []} ->
@ -305,17 +340,23 @@ defmodule MvWeb.OidcE2EFlowTest do
# Registration should trigger password requirement
{:error, %Ash.Error.Invalid{errors: errors}} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
assert Enum.any?(errors, fn err ->
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
end)
end
test "E2E: user with oidc_id cannot be hijacked by different OIDC provider", %{conn: _conn} do
test "E2E: user with oidc_id cannot be hijacked by different OIDC provider", %{
conn: _conn,
actor: actor
} do
# User linked to OIDC provider A
_user =
create_test_user(%{
@ -331,10 +372,13 @@ defmodule MvWeb.OidcE2EFlowTest do
# Should trigger hard error (not PasswordVerificationRequired)
{:error, %Ash.Error.Invalid{errors: errors}} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
# Should have hard error about "already linked to a different OIDC account"
assert Enum.any?(errors, fn
@ -351,7 +395,10 @@ defmodule MvWeb.OidcE2EFlowTest do
end)
end
test "E2E: empty string oidc_id is treated as password-only account", %{conn: _conn} do
test "E2E: empty string oidc_id is treated as password-only account", %{
conn: _conn,
actor: actor
} do
# User with empty oidc_id
_password_user =
create_test_user(%{
@ -367,10 +414,13 @@ defmodule MvWeb.OidcE2EFlowTest do
}
{:error, %Ash.Error.Invalid{errors: errors}} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
# Should require password (empty string = no OIDC)
assert Enum.any?(errors, fn err ->
@ -380,32 +430,38 @@ defmodule MvWeb.OidcE2EFlowTest do
end
describe "E2E: Error scenarios" do
test "E2E: OIDC registration without oidc_id fails", %{conn: _conn} do
test "E2E: OIDC registration without oidc_id fails", %{conn: _conn, actor: actor} do
user_info = %{
"preferred_username" => "noid@example.com"
}
{:error, %Ash.Error.Invalid{errors: errors}} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
assert Enum.any?(errors, fn err ->
match?(%Ash.Error.Changes.InvalidChanges{}, err)
end)
end
test "E2E: OIDC registration without email fails", %{conn: _conn} do
test "E2E: OIDC registration without email fails", %{conn: _conn, actor: actor} do
user_info = %{
"sub" => "noemail_123"
}
{:error, %Ash.Error.Invalid{errors: errors}} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
assert Enum.any?(errors, fn err ->
match?(%Ash.Error.Changes.Required{field: :email}, err)

View file

@ -5,8 +5,13 @@ defmodule MvWeb.OidcEmailUpdateTest do
"""
use MvWeb.ConnCase, async: true
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "OIDC user updates email to available email" do
test "should succeed and update email" do
test "should succeed and update email", %{actor: actor} do
# Create OIDC user
{:ok, oidc_user} =
Mv.Accounts.User
@ -14,7 +19,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
email: "original@example.com"
})
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_123")
|> Ash.create()
|> Ash.create(actor: actor)
# User logs in via OIDC with NEW email
user_info = %{
@ -23,10 +28,13 @@ defmodule MvWeb.OidcEmailUpdateTest do
}
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
},
actor: actor
)
# Should succeed and email should be updated
assert {:ok, updated_user} = result
@ -37,7 +45,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
end
describe "OIDC user updates email to email of passwordless user" do
test "should fail with clear error message" do
test "should fail with clear error message", %{actor: actor} do
# Create OIDC user
{:ok, _oidc_user} =
Mv.Accounts.User
@ -45,7 +53,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
email: "oidcuser@example.com"
})
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_456")
|> Ash.create()
|> Ash.create(actor: actor)
# Create passwordless user with target email
{:ok, _passwordless_user} =
@ -53,7 +61,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
|> Ash.Changeset.for_create(:create_user, %{
email: "taken@example.com"
})
|> Ash.create()
|> Ash.create(actor: actor)
# OIDC user tries to update email to taken email
user_info = %{
@ -62,10 +70,13 @@ defmodule MvWeb.OidcEmailUpdateTest do
}
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
},
actor: actor
)
# Should fail with email update conflict error
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
@ -88,7 +99,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
end
describe "OIDC user updates email to email of password-protected user" do
test "should fail with clear error message" do
test "should fail with clear error message", %{actor: actor} do
# Create OIDC user
{:ok, _oidc_user} =
Mv.Accounts.User
@ -96,7 +107,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
email: "oidcuser2@example.com"
})
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_789")
|> Ash.create()
|> Ash.create(actor: actor)
# Create password user with target email (explicitly NO oidc_id)
password_user =
@ -106,14 +117,14 @@ defmodule MvWeb.OidcEmailUpdateTest do
})
# Ensure it's a password-only user
{:ok, password_user} = Ash.reload(password_user)
{:ok, password_user} = Ash.reload(password_user, actor: actor)
assert not is_nil(password_user.hashed_password)
# Force oidc_id to be nil to avoid any confusion
{:ok, password_user} =
password_user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.force_change_attribute(:oidc_id, nil)
|> Ash.update()
|> Ash.update(actor: actor)
assert is_nil(password_user.oidc_id)
@ -124,10 +135,13 @@ defmodule MvWeb.OidcEmailUpdateTest do
}
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
},
actor: actor
)
# Should fail with email update conflict error
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
@ -150,7 +164,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
end
describe "OIDC user updates email to email of different OIDC user" do
test "should fail with clear error message about different OIDC account" do
test "should fail with clear error message about different OIDC account", %{actor: actor} do
# Create first OIDC user
{:ok, _oidc_user1} =
Mv.Accounts.User
@ -158,7 +172,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
email: "oidcuser1@example.com"
})
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_aaa")
|> Ash.create()
|> Ash.create(actor: actor)
# Create second OIDC user with target email
{:ok, _oidc_user2} =
@ -167,7 +181,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
email: "oidcuser2@example.com"
})
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_bbb")
|> Ash.create()
|> Ash.create(actor: actor)
# First OIDC user tries to update email to second user's email
user_info = %{
@ -176,10 +190,13 @@ defmodule MvWeb.OidcEmailUpdateTest do
}
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
},
actor: actor
)
# Should fail with "already linked to different OIDC account" error
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
@ -201,14 +218,14 @@ defmodule MvWeb.OidcEmailUpdateTest do
end
describe "New OIDC user registration scenarios (for comparison)" do
test "new OIDC user with email of passwordless user triggers linking flow" do
test "new OIDC user with email of passwordless user triggers linking flow", %{actor: actor} do
# Create passwordless user
{:ok, passwordless_user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:create_user, %{
email: "passwordless@example.com"
})
|> Ash.create()
|> Ash.create(actor: actor)
# New OIDC user tries to register
user_info = %{
@ -217,10 +234,13 @@ defmodule MvWeb.OidcEmailUpdateTest do
}
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
},
actor: actor
)
# Should trigger PasswordVerificationRequired (linking flow)
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
@ -234,7 +254,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
end)
end
test "new OIDC user with email of existing OIDC user shows hard error" do
test "new OIDC user with email of existing OIDC user shows hard error", %{actor: actor} do
# Create existing OIDC user
{:ok, _existing_oidc_user} =
Mv.Accounts.User
@ -242,7 +262,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
email: "existing@example.com"
})
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_existing")
|> Ash.create()
|> Ash.create(actor: actor)
# New OIDC user tries to register with same email
user_info = %{
@ -251,10 +271,13 @@ defmodule MvWeb.OidcEmailUpdateTest do
}
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
},
actor: actor
)
# Should fail with "already linked to different OIDC account" error
assert {:error, %Ash.Error.Invalid{errors: errors}} = result

View file

@ -4,6 +4,11 @@ defmodule MvWeb.OidcIntegrationTest do
# Test OIDC callback scenarios by directly calling the actions
# This simulates what happens during real OIDC authentication
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "OIDC sign-in scenarios" do
test "existing OIDC user with unchanged email can sign in" do
# Create user with OIDC ID
@ -20,11 +25,16 @@ defmodule MvWeb.OidcIntegrationTest do
}
# Test sign_in_with_rauthy action directly
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, [found_user]} =
Mv.Accounts.read_sign_in_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.read_sign_in_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: system_actor
)
assert found_user.id == user.id
assert to_string(found_user.email) == "existing@example.com"
@ -39,10 +49,15 @@ defmodule MvWeb.OidcIntegrationTest do
}
# Test register_with_rauthy action
case Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
}) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
case Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: system_actor
) do
{:ok, new_user} ->
assert to_string(new_user.email) == "newuser@example.com"
assert new_user.oidc_id == "brand_new_oidc_456"
@ -73,11 +88,16 @@ defmodule MvWeb.OidcIntegrationTest do
}
# Should NOT find any user (security requirement)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
result =
Mv.Accounts.read_sign_in_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.read_sign_in_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: system_actor
)
# Either returns empty list OR authentication error - both mean "user not found"
case result do
@ -107,11 +127,16 @@ defmodule MvWeb.OidcIntegrationTest do
"preferred_username" => "oidc.user@example.com"
}
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, [found_user]} =
Mv.Accounts.read_sign_in_with_rauthy(%{
user_info: correct_user_info,
oauth_tokens: %{}
})
Mv.Accounts.read_sign_in_with_rauthy(
%{
user_info: correct_user_info,
oauth_tokens: %{}
},
actor: system_actor
)
assert found_user.id == user.id
@ -122,10 +147,13 @@ defmodule MvWeb.OidcIntegrationTest do
}
result =
Mv.Accounts.read_sign_in_with_rauthy(%{
user_info: wrong_user_info,
oauth_tokens: %{}
})
Mv.Accounts.read_sign_in_with_rauthy(
%{
user_info: wrong_user_info,
oauth_tokens: %{}
},
actor: system_actor
)
# Either returns empty list OR authentication error - both mean "user not found"
case result do
@ -154,11 +182,16 @@ defmodule MvWeb.OidcIntegrationTest do
"preferred_username" => "empty.oidc@example.com"
}
system_actor = Mv.Helpers.SystemActor.get_system_actor()
result =
Mv.Accounts.read_sign_in_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.read_sign_in_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: system_actor
)
# Either returns empty list OR authentication error - both mean "user not found"
case result do
@ -189,11 +222,16 @@ defmodule MvWeb.OidcIntegrationTest do
"preferred_username" => "conflict@example.com"
}
system_actor = Mv.Helpers.SystemActor.get_system_actor()
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: system_actor
)
# Should fail with hard error (not PasswordVerificationRequired)
# This prevents someone with OIDC provider B from taking over an account
@ -220,11 +258,16 @@ defmodule MvWeb.OidcIntegrationTest do
"preferred_username" => "nosub@example.com"
}
system_actor = Mv.Helpers.SystemActor.get_system_actor()
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: system_actor
)
assert {:error,
%Ash.Error.Invalid{
@ -239,11 +282,16 @@ defmodule MvWeb.OidcIntegrationTest do
"sub" => "noemail_oidc_123"
}
system_actor = Mv.Helpers.SystemActor.get_system_actor()
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: system_actor
)
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
@ -264,11 +312,16 @@ defmodule MvWeb.OidcIntegrationTest do
"preferred_username" => "new@example.com"
}
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, user} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: system_actor
)
assert user.id == existing_user.id
assert to_string(user.email) == "new@example.com"
@ -281,11 +334,16 @@ defmodule MvWeb.OidcIntegrationTest do
"preferred_username" => "altid@example.com"
}
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, user} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: system_actor
)
assert user.oidc_id == "alt_oidc_id_123"
assert to_string(user.email) == "altid@example.com"

View file

@ -8,9 +8,15 @@ defmodule MvWeb.OidcPasswordLinkingTest do
use MvWeb.ConnCase, async: true
require Ash.Query
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "OIDC login with existing email (no oidc_id) - Email Collision" do
@tag :test_proposal
test "OIDC register with existing password user email fails with PasswordVerificationRequired" do
test "OIDC register with existing password user email fails with PasswordVerificationRequired",
%{actor: actor} do
# Create password-only user
existing_user =
create_test_user(%{
@ -26,10 +32,13 @@ defmodule MvWeb.OidcPasswordLinkingTest do
}
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
# Should fail with PasswordVerificationRequired error
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
@ -47,7 +56,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
end
@tag :test_proposal
test "PasswordVerificationRequired error contains necessary context" do
test "PasswordVerificationRequired error contains necessary context", %{actor: actor} do
existing_user =
create_test_user(%{
email: "test@example.com",
@ -61,10 +70,13 @@ defmodule MvWeb.OidcPasswordLinkingTest do
}
{:error, %Ash.Error.Invalid{errors: errors}} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
password_error =
Enum.find(errors, fn err ->
@ -78,7 +90,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
end
@tag :test_proposal
test "after successful password verification, oidc_id can be set" do
test "after successful password verification, oidc_id can be set", %{actor: actor} do
# Create password user
user =
create_test_user(%{
@ -97,12 +109,12 @@ defmodule MvWeb.OidcPasswordLinkingTest do
{:ok, updated_user} =
Mv.Accounts.User
|> Ash.Query.filter(id == ^user.id)
|> Ash.read_one!()
|> Ash.read_one!(actor: actor)
|> Ash.Changeset.for_update(:link_oidc_id, %{
oidc_id: user_info["sub"],
oidc_user_info: user_info
})
|> Ash.update()
|> Ash.update(actor: actor)
assert updated_user.id == user.id
assert updated_user.oidc_id == "linked_oidc_555"
@ -112,7 +124,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
end
@tag :test_proposal
test "password verification with wrong password keeps oidc_id as nil" do
test "password verification with wrong password keeps oidc_id as nil", %{actor: actor} do
# This test verifies that if password verification fails,
# the oidc_id should NOT be set
@ -131,7 +143,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
# before link_oidc_id is called, so here we just verify the user state
# User should still have no oidc_id (no linking happened)
{:ok, unchanged_user} = Ash.get(Mv.Accounts.User, user.id)
{:ok, unchanged_user} = Ash.get(Mv.Accounts.User, user.id, actor: actor)
assert is_nil(unchanged_user.oidc_id)
assert unchanged_user.hashed_password == user.hashed_password
end
@ -139,7 +151,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
describe "OIDC login with email of user having different oidc_id - Account Conflict" do
@tag :test_proposal
test "OIDC register with email of user having different oidc_id fails" do
test "OIDC register with email of user having different oidc_id fails", %{actor: actor} do
# User already linked to OIDC provider A
_existing_user =
create_test_user(%{
@ -155,10 +167,13 @@ defmodule MvWeb.OidcPasswordLinkingTest do
}
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
# Should fail - cannot link different OIDC account to same email
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
@ -171,7 +186,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
end
@tag :test_proposal
test "existing OIDC user email remains unchanged when oidc_id matches" do
test "existing OIDC user email remains unchanged when oidc_id matches", %{actor: actor} do
user =
create_test_user(%{
email: "oidc@example.com",
@ -186,10 +201,13 @@ defmodule MvWeb.OidcPasswordLinkingTest do
# This should work via upsert
{:ok, updated_user} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
assert updated_user.id == user.id
assert updated_user.oidc_id == "oidc_stable_789"
@ -199,7 +217,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
describe "Email update during OIDC linking" do
@tag :test_proposal
test "linking OIDC to password account updates email if different in OIDC" do
test "linking OIDC to password account updates email if different in OIDC", %{actor: actor} do
# Password user with old email
user =
create_test_user(%{
@ -218,19 +236,19 @@ defmodule MvWeb.OidcPasswordLinkingTest do
{:ok, updated_user} =
Mv.Accounts.User
|> Ash.Query.filter(id == ^user.id)
|> Ash.read_one!()
|> Ash.read_one!(actor: actor)
|> Ash.Changeset.for_update(:link_oidc_id, %{
oidc_id: user_info["sub"],
oidc_user_info: user_info
})
|> Ash.update()
|> Ash.update(actor: actor)
assert updated_user.oidc_id == "oidc_link_999"
assert to_string(updated_user.email) == "newemail@example.com"
end
@tag :test_proposal
test "email change during linking triggers member email sync" do
test "email change during linking triggers member email sync", %{actor: actor} do
# Create member
member =
Ash.Seed.seed!(Mv.Membership.Member, %{
@ -257,25 +275,25 @@ defmodule MvWeb.OidcPasswordLinkingTest do
{:ok, updated_user} =
Mv.Accounts.User
|> Ash.Query.filter(id == ^user.id)
|> Ash.read_one!()
|> Ash.read_one!(actor: actor)
|> Ash.Changeset.for_update(:link_oidc_id, %{
oidc_id: user_info["sub"],
oidc_user_info: user_info
})
|> Ash.update()
|> Ash.update(actor: actor)
# Verify user email changed
assert to_string(updated_user.email) == "newemail@example.com"
# Verify member email was synced
{:ok, updated_member} = Ash.get(Mv.Membership.Member, member.id)
{:ok, updated_member} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
assert to_string(updated_member.email) == "newemail@example.com"
end
end
describe "Edge cases" do
@tag :test_proposal
test "user with empty string oidc_id is treated as password-only user" do
test "user with empty string oidc_id is treated as password-only user", %{actor: actor} do
_user =
create_test_user(%{
email: "empty@example.com",
@ -290,10 +308,13 @@ defmodule MvWeb.OidcPasswordLinkingTest do
}
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
# Should trigger PasswordVerificationRequired (empty string = no OIDC)
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
@ -307,7 +328,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
end
@tag :test_proposal
test "cannot link same oidc_id to multiple users" do
test "cannot link same oidc_id to multiple users", %{actor: actor} do
# User 1 with OIDC
_user1 =
create_test_user(%{
@ -323,7 +344,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
email: "user2@example.com"
})
|> Ash.Changeset.force_change_attribute(:oidc_id, "shared_oidc_333")
|> Ash.create()
|> Ash.create(actor: actor)
# Should fail due to unique constraint on oidc_id
assert match?({:error, %Ash.Error.Invalid{}}, result)
@ -337,14 +358,16 @@ defmodule MvWeb.OidcPasswordLinkingTest do
end
describe "OIDC login with passwordless user - Requires Linking Flow" do
test "user without password and without oidc_id triggers PasswordVerificationRequired" do
test "user without password and without oidc_id triggers PasswordVerificationRequired", %{
actor: actor
} do
# Create user without password (e.g., invited user)
{:ok, existing_user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:create_user, %{
email: "invited@example.com"
})
|> Ash.create()
|> Ash.create(actor: actor)
# Verify user has no password and no oidc_id
assert is_nil(existing_user.hashed_password)
@ -372,14 +395,14 @@ defmodule MvWeb.OidcPasswordLinkingTest do
end)
end
test "user without password but WITH password later requires verification" do
test "user without password but WITH password later requires verification", %{actor: actor} do
# Create user without password first
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:create_user, %{
email: "added-password@example.com"
})
|> Ash.create()
|> Ash.create(actor: actor)
# User sets password later (using admin action)
{:ok, user_with_password} =
@ -387,7 +410,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|> Ash.Changeset.for_update(:admin_set_password, %{
password: "newpassword123"
})
|> Ash.update()
|> Ash.update(actor: actor)
assert not is_nil(user_with_password.hashed_password)
@ -398,10 +421,13 @@ defmodule MvWeb.OidcPasswordLinkingTest do
}
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
},
actor: actor
)
# Should fail with PasswordVerificationRequired
assert {:error, %Ash.Error.Invalid{}} = result
@ -414,7 +440,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
end
describe "OIDC login with different oidc_id - Hard Error" do
test "user with different oidc_id cannot be linked (hard error)" do
test "user with different oidc_id cannot be linked (hard error)", %{actor: actor} do
# Create user with existing OIDC ID
{:ok, existing_user} =
Mv.Accounts.User
@ -422,7 +448,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
email: "already-linked@example.com"
})
|> Ash.Changeset.force_change_attribute(:oidc_id, "original_oidc_999")
|> Ash.create()
|> Ash.create(actor: actor)
assert existing_user.oidc_id == "original_oidc_999"
@ -433,10 +459,13 @@ defmodule MvWeb.OidcPasswordLinkingTest do
}
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
},
actor: actor
)
# Should fail with hard error (not PasswordVerificationRequired)
assert {:error, %Ash.Error.Invalid{}} = result
@ -459,7 +488,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
end)
end
test "cannot link different oidc_id even with password verification" do
test "cannot link different oidc_id even with password verification", %{actor: actor} do
# Create user with password AND existing OIDC ID
existing_user =
create_test_user(%{
@ -478,10 +507,13 @@ defmodule MvWeb.OidcPasswordLinkingTest do
}
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
},
actor: actor
)
# Should fail - cannot link different OIDC ID
assert {:error, %Ash.Error.Invalid{}} = result

View file

@ -7,15 +7,20 @@ defmodule MvWeb.OidcPasswordlessLinkingTest do
"""
use MvWeb.ConnCase, async: true
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "Passwordless user - Automatic linking via special action" do
test "passwordless user can be linked via link_passwordless_oidc action" do
test "passwordless user can be linked via link_passwordless_oidc action", %{actor: actor} do
# Create user without password (e.g., invited user)
{:ok, existing_user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:create_user, %{
email: "invited@example.com"
})
|> Ash.create()
|> Ash.create(actor: actor)
# Verify user has no password and no oidc_id
assert is_nil(existing_user.hashed_password)
@ -31,7 +36,7 @@ defmodule MvWeb.OidcPasswordlessLinkingTest do
"preferred_username" => "invited@example.com"
}
})
|> Ash.update()
|> Ash.update(actor: actor)
# User should now have oidc_id linked
assert linked_user.oidc_id == "auto_link_oidc_123"
@ -47,20 +52,22 @@ defmodule MvWeb.OidcPasswordlessLinkingTest do
},
oauth_tokens: %{"access_token" => "test_token"}
})
|> Ash.read_one()
|> Ash.read_one(actor: actor)
assert {:ok, signed_in_user} = result
assert signed_in_user.id == existing_user.id
end
test "passwordless user triggers PasswordVerificationRequired for linking flow" do
test "passwordless user triggers PasswordVerificationRequired for linking flow", %{
actor: actor
} do
# Create passwordless user
{:ok, existing_user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:create_user, %{
email: "passwordless@example.com"
})
|> Ash.create()
|> Ash.create(actor: actor)
assert is_nil(existing_user.hashed_password)
assert is_nil(existing_user.oidc_id)
@ -95,7 +102,7 @@ defmodule MvWeb.OidcPasswordlessLinkingTest do
end
describe "User with different OIDC ID - Hard Error" do
test "user with different oidc_id gets hard error, not password verification" do
test "user with different oidc_id gets hard error, not password verification", %{actor: actor} do
# Create user with existing OIDC ID
{:ok, _existing_user} =
Mv.Accounts.User
@ -103,7 +110,7 @@ defmodule MvWeb.OidcPasswordlessLinkingTest do
email: "already-linked@example.com"
})
|> Ash.Changeset.force_change_attribute(:oidc_id, "original_oidc_999")
|> Ash.create()
|> Ash.create(actor: actor)
# Try to register with same email but different OIDC ID
user_info = %{
@ -138,7 +145,7 @@ defmodule MvWeb.OidcPasswordlessLinkingTest do
end)
end
test "passwordless user with different oidc_id also gets hard error" do
test "passwordless user with different oidc_id also gets hard error", %{actor: actor} do
# Create passwordless user with OIDC ID
{:ok, existing_user} =
Mv.Accounts.User
@ -146,7 +153,7 @@ defmodule MvWeb.OidcPasswordlessLinkingTest do
email: "passwordless-linked@example.com"
})
|> Ash.Changeset.force_change_attribute(:oidc_id, "first_oidc_777")
|> Ash.create()
|> Ash.create(actor: actor)
assert is_nil(existing_user.hashed_password)
assert existing_user.oidc_id == "first_oidc_777"

View file

@ -9,6 +9,11 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
alias MvWeb.Helpers.MembershipFeeHelpers
alias Mv.MembershipFees.CalendarCycles
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "format_currency/1" do
test "formats decimal amount correctly" do
assert MembershipFeeHelpers.format_currency(Decimal.new("60.00")) == "60,00 €"
@ -63,7 +68,7 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
end
describe "get_last_completed_cycle/2" do
test "returns last completed cycle for member" do
test "returns last completed cycle for member", %{actor: actor} do
# Create test data
fee_type =
Mv.MembershipFees.MembershipFeeType
@ -72,7 +77,7 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
amount: Decimal.new("50.00"),
interval: :yearly
})
|> Ash.create!()
|> Ash.create!(actor: actor)
# Create member without fee type first to avoid auto-generation
member =
@ -83,21 +88,21 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2022-01-01]
})
|> Ash.create!()
|> Ash.create!(actor: actor)
# Assign fee type after member creation (this may generate cycles, but we'll create our own)
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
|> Ash.update!(actor: actor)
# Delete any auto-generated cycles first
cycles =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
|> Ash.read!(actor: actor)
Enum.each(cycles, fn cycle -> Ash.destroy!(cycle) end)
Enum.each(cycles, fn cycle -> Ash.destroy!(cycle, actor: actor) end)
# Create cycles manually
_cycle_2022 =
@ -109,7 +114,7 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
membership_fee_type_id: fee_type.id,
status: :paid
})
|> Ash.create!()
|> Ash.create!(actor: actor)
cycle_2023 =
Mv.MembershipFees.MembershipFeeCycle
@ -120,7 +125,7 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
membership_fee_type_id: fee_type.id,
status: :paid
})
|> Ash.create!()
|> Ash.create!(actor: actor)
# Load cycles with membership_fee_type relationship
member =
@ -135,7 +140,7 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
assert last_cycle.id == cycle_2023.id
end
test "returns nil if no cycles exist" do
test "returns nil if no cycles exist", %{actor: actor} do
fee_type =
Mv.MembershipFees.MembershipFeeType
|> Ash.Changeset.for_create(:create, %{
@ -143,7 +148,7 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
amount: Decimal.new("50.00"),
interval: :yearly
})
|> Ash.create!()
|> Ash.create!(actor: actor)
# Create member without fee type first
member =
@ -153,21 +158,21 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create!()
|> Ash.create!(actor: actor)
# Assign fee type
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
|> Ash.update!(actor: actor)
# Delete any auto-generated cycles
cycles =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
|> Ash.read!(actor: actor)
Enum.each(cycles, fn cycle -> Ash.destroy!(cycle) end)
Enum.each(cycles, fn cycle -> Ash.destroy!(cycle, actor: actor) end)
# Load cycles and fee type (will be empty)
member =
@ -181,7 +186,7 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
end
describe "get_current_cycle/2" do
test "returns current cycle for member" do
test "returns current cycle for member", %{actor: actor} do
fee_type =
Mv.MembershipFees.MembershipFeeType
|> Ash.Changeset.for_create(:create, %{
@ -189,7 +194,7 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
amount: Decimal.new("50.00"),
interval: :yearly
})
|> Ash.create!()
|> Ash.create!(actor: actor)
# Create member without fee type first
member =
@ -200,21 +205,21 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-01-01]
})
|> Ash.create!()
|> Ash.create!(actor: actor)
# Assign fee type
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
|> Ash.update!(actor: actor)
# Delete any auto-generated cycles
cycles =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
|> Ash.read!(actor: actor)
Enum.each(cycles, fn cycle -> Ash.destroy!(cycle) end)
Enum.each(cycles, fn cycle -> Ash.destroy!(cycle, actor: actor) end)
today = Date.utc_today()
current_year_start = %{today | month: 1, day: 1}
@ -228,7 +233,7 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
membership_fee_type_id: fee_type.id,
status: :unpaid
})
|> Ash.create!()
|> Ash.create!(actor: actor)
# Load cycles with membership_fee_type relationship
member =

View file

@ -19,6 +19,8 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create admin user for testing
{:ok, user} =
Mv.Accounts.User
@ -26,7 +28,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
conn = log_in_user(build_conn(), user)
%{conn: conn, user: user}
@ -156,14 +158,16 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
# Should show success message
assert render(view) =~ "Data field deleted successfully"
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Custom field should be gone from database
assert {:error, _} = Ash.get(CustomField, custom_field.id)
assert {:error, _} = Ash.get(CustomField, custom_field.id, actor: system_actor)
# Custom field value should also be gone (CASCADE)
assert {:error, _} = Ash.get(CustomFieldValue, custom_field_value.id)
assert {:error, _} = Ash.get(CustomFieldValue, custom_field_value.id, actor: system_actor)
# Member should still exist
assert {:ok, _} = Ash.get(Member, member.id)
assert {:ok, _} = Ash.get(Member, member.id, actor: system_actor)
end
test "button remains disabled and custom field not deleted when slug doesn't match", %{
@ -188,7 +192,8 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
assert html =~ ~r/disabled(?:=""|(?!\w))/
# Custom field should still exist since deletion couldn't proceed
assert {:ok, _} = Ash.get(CustomField, custom_field.id)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
assert {:ok, _} = Ash.get(CustomField, custom_field.id, actor: system_actor)
end
end
@ -214,38 +219,45 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
refute has_element?(view, "#delete-custom-field-modal")
# Custom field should still exist
assert {:ok, _} = Ash.get(CustomField, custom_field.id)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
assert {:ok, _} = Ash.get(CustomField, custom_field.id, actor: system_actor)
end
end
# Helper functions
defp create_member do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User#{System.unique_integer([:positive])}",
email: "test#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
end
defp create_custom_field(name, value_type) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "#{name}_#{System.unique_integer([:positive])}",
value_type: value_type
})
|> Ash.create()
|> Ash.create(actor: system_actor)
end
defp create_custom_field_value(member, custom_field, value) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: custom_field.id,
value: %{"_union_type" => "string", "_union_value" => value}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
end
defp log_in_user(conn, user) do

View file

@ -12,6 +12,8 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
require Ash.Query
setup %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create admin user
{:ok, user} =
Mv.Accounts.User
@ -19,7 +21,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
authenticated_conn = conn_with_password_user(conn, user)
%{conn: authenticated_conn, user: user}
@ -27,6 +29,8 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
# Helper to create a membership fee type
defp create_fee_type(attrs) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
@ -37,11 +41,13 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
|> 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",
@ -52,7 +58,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
|> Ash.create!(actor: system_actor)
end
describe "create form" do

View file

@ -2,6 +2,11 @@ defmodule MvWeb.ProfileNavigationTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "profile navigation" do
test "clicking profile button redirects to current user profile", %{conn: conn} do
# Setup: Create and login a user
@ -60,7 +65,7 @@ defmodule MvWeb.ProfileNavigationTest do
end
describe "profile navigation with OIDC user" do
test "shows correct profile data for OIDC user", %{conn: conn} do
test "shows correct profile data for OIDC user", %{conn: conn, actor: actor} do
# Setup: Create OIDC user with sub claim
user_info = %{
"sub" => "oidc_123",
@ -78,7 +83,7 @@ defmodule MvWeb.ProfileNavigationTest do
user_info: user_info,
oauth_tokens: oauth_tokens
})
|> Ash.create!(domain: Mv.Accounts)
|> Ash.create!(domain: Mv.Accounts, actor: actor)
# Login user via OIDC
conn = sign_in_user_via_oidc(conn, user)
@ -94,7 +99,10 @@ defmodule MvWeb.ProfileNavigationTest do
assert html =~ "Not enabled"
end
test "profile navigation works across different authentication methods", %{conn: conn} do
test "profile navigation works across different authentication methods", %{
conn: conn,
actor: actor
} do
# Create password user
password_user =
create_test_user(%{
@ -119,7 +127,7 @@ defmodule MvWeb.ProfileNavigationTest do
user_info: user_info,
oauth_tokens: oauth_tokens
})
|> Ash.create!(domain: Mv.Accounts)
|> Ash.create!(domain: Mv.Accounts, actor: actor)
# Test with password user
conn_password = conn_with_password_user(conn, password_user)

View file

@ -35,7 +35,7 @@ defmodule MvWeb.RoleLive.ShowTest do
end
# Helper to create admin user with admin role
defp create_admin_user(conn) do
defp create_admin_user(conn, actor) do
# Create admin role
admin_role =
case Authorization.list_roles() do
@ -69,14 +69,14 @@ defmodule MvWeb.RoleLive.ShowTest do
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
|> Ash.create(actor: actor)
# Assign admin role using manage_relationship
{:ok, user} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update()
|> Ash.update(actor: actor)
# Load role for authorization checks (must be loaded for can?/3 to work)
user_with_role = Ash.load!(user, :role, domain: Mv.Accounts)
@ -88,8 +88,9 @@ defmodule MvWeb.RoleLive.ShowTest do
describe "mount and display" do
setup %{conn: conn} do
{conn, _user, _admin_role} = create_admin_user(conn)
%{conn: conn}
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{conn, _user, _admin_role} = create_admin_user(conn, system_actor)
%{conn: conn, actor: system_actor}
end
test "mounts successfully with valid role ID", %{conn: conn} do
@ -135,7 +136,7 @@ defmodule MvWeb.RoleLive.ShowTest do
assert html =~ gettext("Permission Set")
end
test "displays system role badge when is_system_role is true", %{conn: conn} do
test "displays system role badge when is_system_role is true", %{conn: conn, actor: actor} do
system_role =
Role
|> Ash.Changeset.for_create(:create_role, %{
@ -143,7 +144,7 @@ defmodule MvWeb.RoleLive.ShowTest do
permission_set_name: "own_data"
})
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|> Ash.create!()
|> Ash.create!(actor: actor)
{:ok, _view, html} = live(conn, "/admin/roles/#{system_role.id}")
@ -172,8 +173,9 @@ defmodule MvWeb.RoleLive.ShowTest do
describe "navigation" do
setup %{conn: conn} do
{conn, _user, _admin_role} = create_admin_user(conn)
%{conn: conn}
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{conn, _user, _admin_role} = create_admin_user(conn, system_actor)
%{conn: conn, actor: system_actor}
end
test "back button navigates to role list", %{conn: conn} do
@ -209,8 +211,9 @@ defmodule MvWeb.RoleLive.ShowTest do
describe "error handling" do
setup %{conn: conn} do
{conn, _user, _admin_role} = create_admin_user(conn)
%{conn: conn}
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{conn, _user, _admin_role} = create_admin_user(conn, system_actor)
%{conn: conn, actor: system_actor}
end
test "redirects to role list with error for invalid role ID", %{conn: conn} do
@ -226,11 +229,12 @@ defmodule MvWeb.RoleLive.ShowTest do
describe "delete functionality" do
setup %{conn: conn} do
{conn, _user, _admin_role} = create_admin_user(conn)
%{conn: conn}
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{conn, _user, _admin_role} = create_admin_user(conn, system_actor)
%{conn: conn, actor: system_actor}
end
test "delete button is not shown for system roles", %{conn: conn} do
test "delete button is not shown for system roles", %{conn: conn, actor: actor} do
system_role =
Role
|> Ash.Changeset.for_create(:create_role, %{
@ -238,7 +242,7 @@ defmodule MvWeb.RoleLive.ShowTest do
permission_set_name: "own_data"
})
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|> Ash.create!()
|> Ash.create!(actor: actor)
{:ok, _view, html} = live(conn, "/admin/roles/#{system_role.id}")
@ -258,8 +262,9 @@ defmodule MvWeb.RoleLive.ShowTest do
describe "page title" do
setup %{conn: conn} do
{conn, _user, _admin_role} = create_admin_user(conn)
%{conn: conn}
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{conn, _user, _admin_role} = create_admin_user(conn, system_actor)
%{conn: conn, actor: system_actor}
end
test "sets correct page title", %{conn: conn} do

View file

@ -26,7 +26,7 @@ defmodule MvWeb.RoleLiveTest do
end
# Helper to create admin user with admin role
defp create_admin_user(conn) do
defp create_admin_user(conn, actor) do
# Create admin role
admin_role =
case Authorization.list_roles() do
@ -60,14 +60,14 @@ defmodule MvWeb.RoleLiveTest do
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
|> Ash.create(actor: actor)
# Assign admin role using manage_relationship
{:ok, user} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update()
|> Ash.update(actor: actor)
# Load role for authorization checks (must be loaded for can?/3 to work)
user_with_role = Ash.load!(user, :role, domain: Mv.Accounts)
@ -78,14 +78,14 @@ defmodule MvWeb.RoleLiveTest do
end
# Helper to create non-admin user
defp create_non_admin_user(conn) do
defp create_non_admin_user(conn, actor) do
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "user#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
|> Ash.create(actor: actor)
conn = conn_with_password_user(conn, user)
{conn, user}
@ -93,8 +93,9 @@ defmodule MvWeb.RoleLiveTest do
describe "index page" do
setup %{conn: conn} do
{conn, user, _admin_role} = create_admin_user(conn)
%{conn: conn, user: user}
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{conn, user, _admin_role} = create_admin_user(conn, system_actor)
%{conn: conn, actor: system_actor, user: user}
end
test "mounts successfully", %{conn: conn} do
@ -121,7 +122,7 @@ defmodule MvWeb.RoleLiveTest do
assert html =~ role.permission_set_name
end
test "shows system role badge", %{conn: conn} do
test "shows system role badge", %{conn: conn, actor: actor} do
_system_role =
Role
|> Ash.Changeset.for_create(:create_role, %{
@ -129,14 +130,14 @@ defmodule MvWeb.RoleLiveTest do
permission_set_name: "own_data"
})
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|> Ash.create!()
|> Ash.create!(actor: actor)
{:ok, _view, html} = live(conn, "/admin/roles")
assert html =~ "System Role" || html =~ "system"
end
test "delete button disabled for system roles", %{conn: conn} do
test "delete button disabled for system roles", %{conn: conn, actor: actor} do
system_role =
Role
|> Ash.Changeset.for_create(:create_role, %{
@ -144,7 +145,7 @@ defmodule MvWeb.RoleLiveTest do
permission_set_name: "own_data"
})
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|> Ash.create!()
|> Ash.create!(actor: actor)
{:ok, view, _html} = live(conn, "/admin/roles")
@ -191,8 +192,9 @@ defmodule MvWeb.RoleLiveTest do
describe "show page" do
setup %{conn: conn} do
{conn, user, _admin_role} = create_admin_user(conn)
%{conn: conn, user: user}
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{conn, user, _admin_role} = create_admin_user(conn, system_actor)
%{conn: conn, actor: system_actor, user: user}
end
test "mounts with valid role ID", %{conn: conn} do
@ -215,7 +217,7 @@ defmodule MvWeb.RoleLiveTest do
assert match?({:error, {:redirect, %{to: "/admin/roles"}}}, result)
end
test "shows system role badge if is_system_role is true", %{conn: conn} do
test "shows system role badge if is_system_role is true", %{conn: conn, actor: actor} do
system_role =
Role
|> Ash.Changeset.for_create(:create_role, %{
@ -223,7 +225,7 @@ defmodule MvWeb.RoleLiveTest do
permission_set_name: "own_data"
})
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|> Ash.create!()
|> Ash.create!(actor: actor)
{:ok, _view, html} = live(conn, "/admin/roles/#{system_role.id}")
@ -233,8 +235,9 @@ defmodule MvWeb.RoleLiveTest do
describe "form - create" do
setup %{conn: conn} do
{conn, user, _admin_role} = create_admin_user(conn)
%{conn: conn, user: user}
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{conn, user, _admin_role} = create_admin_user(conn, system_actor)
%{conn: conn, actor: system_actor, user: user}
end
test "mounts successfully", %{conn: conn} do
@ -306,9 +309,10 @@ defmodule MvWeb.RoleLiveTest do
describe "form - edit" do
setup %{conn: conn} do
{conn, user, _admin_role} = create_admin_user(conn)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{conn, user, _admin_role} = create_admin_user(conn, system_actor)
role = create_role()
%{conn: conn, user: user, role: role}
%{conn: conn, actor: system_actor, user: user, role: role}
end
test "mounts with valid role ID", %{conn: conn, role: role} do
@ -347,7 +351,7 @@ defmodule MvWeb.RoleLiveTest do
assert updated_role.name == "Updated Role Name"
end
test "updates system role's permission_set_name", %{conn: conn} do
test "updates system role's permission_set_name", %{conn: conn, actor: actor} do
system_role =
Role
|> Ash.Changeset.for_create(:create_role, %{
@ -355,7 +359,7 @@ defmodule MvWeb.RoleLiveTest do
permission_set_name: "own_data"
})
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|> Ash.create!()
|> Ash.create!(actor: actor)
{:ok, view, _html} = live(conn, "/admin/roles/#{system_role.id}/edit?return_to=show")
@ -379,8 +383,9 @@ defmodule MvWeb.RoleLiveTest do
describe "delete functionality" do
setup %{conn: conn} do
{conn, user, _admin_role} = create_admin_user(conn)
%{conn: conn, user: user}
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{conn, user, _admin_role} = create_admin_user(conn, system_actor)
%{conn: conn, actor: system_actor, user: user}
end
test "deletes non-system role", %{conn: conn} do
@ -400,7 +405,7 @@ defmodule MvWeb.RoleLiveTest do
Authorization.get_role(role.id)
end
test "fails to delete system role with error message", %{conn: conn} do
test "fails to delete system role with error message", %{conn: conn, actor: actor} do
system_role =
Role
|> Ash.Changeset.for_create(:create_role, %{
@ -408,7 +413,7 @@ defmodule MvWeb.RoleLiveTest do
permission_set_name: "own_data"
})
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|> Ash.create!()
|> Ash.create!(actor: actor)
{:ok, view, html} = live(conn, "/admin/roles")
@ -428,8 +433,13 @@ defmodule MvWeb.RoleLiveTest do
end
describe "authorization" do
test "only admin can access /admin/roles", %{conn: conn} do
{conn, _user} = create_non_admin_user(conn)
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
test "only admin can access /admin/roles", %{conn: conn, actor: actor} do
{conn, _user} = create_non_admin_user(conn, actor)
# Non-admin should be redirected or see error
# Note: Authorization is checked via can_access_page? which returns false
@ -443,8 +453,8 @@ defmodule MvWeb.RoleLiveTest do
assert html =~ "Listing Roles" || html =~ "Roles"
end
test "admin can access /admin/roles", %{conn: conn} do
{conn, _user, _admin_role} = create_admin_user(conn)
test "admin can access /admin/roles", %{conn: conn, actor: actor} do
{conn, _user, _admin_role} = create_admin_user(conn, actor)
{:ok, _view, _html} = live(conn, "/admin/roles")
end

View file

@ -64,6 +64,8 @@ defmodule MvWeb.UserLive.ShowTest do
end
test "displays linked member when present", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create member
{:ok, member} =
Member
@ -72,7 +74,7 @@ defmodule MvWeb.UserLive.ShowTest do
last_name: "Smith",
email: "alice@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Create user and link to member
user = create_test_user(%{email: "user@example.com"})
@ -81,7 +83,7 @@ defmodule MvWeb.UserLive.ShowTest do
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:member, member, type: :append_and_remove)
|> Ash.update()
|> Ash.update(actor: system_actor)
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/users/#{user.id}")

View file

@ -12,6 +12,8 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
describe "error handling - flash messages" do
test "shows flash message when member creation fails with validation error", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create a member with the same email to trigger uniqueness error
{:ok, _existing_member} =
Member
@ -20,7 +22,7 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
last_name: "Member",
email: "duplicate@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members/new")
@ -73,6 +75,8 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
end
test "shows flash message when member update fails", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create a member to edit
{:ok, member} =
Member
@ -81,7 +85,7 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
last_name: "Member",
email: "original@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Create another member with different email
{:ok, _other_member} =
@ -91,7 +95,7 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
last_name: "Member",
email: "other@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")

View file

@ -13,6 +13,8 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
# Helper to create a membership fee type
defp create_fee_type(attrs) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
@ -23,11 +25,13 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
|> 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",
@ -38,7 +42,7 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
|> Ash.create!(actor: system_actor)
end
describe "membership fee type dropdown" do
@ -123,10 +127,12 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|> render_submit()
# Verify member was created with fee type
system_actor = Mv.Helpers.SystemActor.get_system_actor()
member =
Member
|> Ash.Query.filter(email == ^form_data["member[email]"])
|> Ash.read_one!()
|> Ash.read_one!(actor: system_actor)
assert member.membership_fee_type_id == fee_type.id
end
@ -135,13 +141,14 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
# Set default fee type in settings
fee_type = create_fee_type(%{interval: :yearly})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, settings} = Mv.Membership.get_settings()
settings
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
default_membership_fee_type_id: fee_type.id
})
|> Ash.update!()
|> Ash.update!(actor: system_actor)
{:ok, view, _html} = live(conn, "/members/new")
@ -156,6 +163,8 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
conn: conn,
current_user: admin_user
} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create custom field
custom_field =
Mv.Membership.CustomField
@ -164,7 +173,7 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
value_type: :string,
required: false
})
|> Ash.create!()
|> Ash.create!(actor: system_actor)
# Create two fee types with same interval
fee_type1 = create_fee_type(%{name: "Type 1", interval: :yearly})
@ -250,6 +259,8 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
end
test "removing custom field values works correctly", %{conn: conn, current_user: admin_user} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create custom field
custom_field =
Mv.Membership.CustomField
@ -258,7 +269,7 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
value_type: :string,
required: false
})
|> Ash.create!()
|> Ash.create!(actor: system_actor)
fee_type = create_fee_type(%{interval: :yearly})

View file

@ -13,6 +13,8 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
# Helper to create a membership fee type
defp create_fee_type(attrs) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
@ -23,11 +25,13 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
|> 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",
@ -38,13 +42,15 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
|> Ash.create!(actor: system_actor)
end
# Helper to create a cycle
# Note: Does not delete existing cycles - tests should manage their own test data
# If cleanup is needed, it should be done in setup or explicitly in the test
defp create_cycle(member, fee_type, attrs) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
default_attrs = %{
cycle_start: ~D[2023-01-01],
amount: Decimal.new("50.00"),
@ -57,7 +63,7 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
|> Ash.create!(actor: system_actor)
end
describe "load_cycles_for_members/2" do
@ -75,7 +81,8 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|> Ash.Query.filter(id in [^member1.id, ^member2.id])
|> MembershipFeeStatus.load_cycles_for_members()
members = Ash.read!(query)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
members = Ash.read!(query, actor: system_actor)
assert length(members) == 2
@ -94,19 +101,21 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
# Create member without fee type to avoid auto-generation
member = create_member(%{})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Assign fee type
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
|> Ash.update!(actor: system_actor)
# Delete any auto-generated cycles
cycles =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
|> Ash.read!(actor: system_actor)
Enum.each(cycles, fn cycle -> Ash.destroy!(cycle) end)
Enum.each(cycles, fn cycle -> Ash.destroy!(cycle, actor: system_actor) end)
# Create cycles with dates that ensure 2023 is last completed
# Use a fixed "today" date in 2024 to make 2023 the last completed
@ -137,19 +146,21 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
# Create member without fee type to avoid auto-generation
member = create_member(%{})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Assign fee type
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
|> Ash.update!(actor: system_actor)
# Delete any auto-generated cycles
cycles =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
|> Ash.read!(actor: system_actor)
Enum.each(cycles, fn cycle -> Ash.destroy!(cycle) end)
Enum.each(cycles, fn cycle -> Ash.destroy!(cycle, actor: system_actor) end)
# Create cycles - use current year for current cycle
today = Date.utc_today()
@ -176,19 +187,21 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
# Create member without fee type to avoid auto-generation
member = create_member(%{})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Assign fee type
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
|> Ash.update!(actor: system_actor)
# Delete any auto-generated cycles
cycles =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
|> Ash.read!(actor: system_actor)
Enum.each(cycles, fn cycle -> Ash.destroy!(cycle) end)
Enum.each(cycles, fn cycle -> Ash.destroy!(cycle, actor: system_actor) end)
# Load cycles and fee type first (will be empty)
member =

View file

@ -14,6 +14,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test member
{:ok, member} =
Member
@ -22,7 +24,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do
last_name: "Anderson",
email: "alice@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Create custom field with show_in_overview: true
{:ok, field} =
@ -32,7 +34,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do
value_type: :string,
show_in_overview: true
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Create custom field value
{:ok, _cfv} =
@ -42,7 +44,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do
custom_field_id: field.id,
value: %{"_union_type" => "string", "_union_value" => "A001"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
%{member: member, field: field}
end

View file

@ -17,6 +17,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test members
{:ok, member1} =
Member
@ -25,7 +27,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
last_name: "Anderson",
email: "alice@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, member2} =
Member
@ -34,7 +36,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
last_name: "Brown",
email: "bob@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Create custom fields
{:ok, field_show_string} =
@ -44,7 +46,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
value_type: :string,
show_in_overview: true
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, field_hide} =
CustomField
@ -53,7 +55,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
value_type: :string,
show_in_overview: false
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, field_show_integer} =
CustomField
@ -62,7 +64,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
value_type: :integer,
show_in_overview: true
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, field_show_boolean} =
CustomField
@ -71,7 +73,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
value_type: :boolean,
show_in_overview: true
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, field_show_date} =
CustomField
@ -80,7 +82,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
value_type: :date,
show_in_overview: true
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, field_show_email} =
CustomField
@ -89,7 +91,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
value_type: :email,
show_in_overview: true
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Create custom field values for member1
{:ok, _cfv1} =
@ -99,7 +101,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
custom_field_id: field_show_string.id,
value: %{"_union_type" => "string", "_union_value" => "+49123456789"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, _cfv2} =
CustomFieldValue
@ -108,7 +110,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
custom_field_id: field_show_integer.id,
value: %{"_union_type" => "integer", "_union_value" => 12_345}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, _cfv3} =
CustomFieldValue
@ -117,7 +119,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
custom_field_id: field_show_boolean.id,
value: %{"_union_type" => "boolean", "_union_value" => true}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, _cfv4} =
CustomFieldValue
@ -126,7 +128,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
custom_field_id: field_show_date.id,
value: %{"_union_type" => "date", "_union_value" => ~D[1990-05-15]}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, _cfv5} =
CustomFieldValue
@ -135,7 +137,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
custom_field_id: field_show_email.id,
value: %{"_union_type" => "email", "_union_value" => "alice.private@example.com"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Create hidden custom field value (should not be displayed)
{:ok, _cfv_hidden} =
@ -145,7 +147,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
custom_field_id: field_hide.id,
value: %{"_union_type" => "string", "_union_value" => "Internal note"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
%{
member1: member1,

View file

@ -13,6 +13,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
alias Mv.Membership.{CustomField, Member}
test "displays custom field column even when no members have values", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test members without custom field values
{:ok, _member1} =
Member
@ -21,7 +23,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
last_name: "Anderson",
email: "alice@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, _member2} =
Member
@ -30,7 +32,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
last_name: "Brown",
email: "bob@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Create custom field with show_in_overview: true but no values
{:ok, field} =
@ -40,7 +42,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
value_type: :string,
show_in_overview: true
})
|> Ash.create()
|> Ash.create(actor: system_actor)
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
@ -50,6 +52,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
end
test "displays very long custom field values correctly", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test member
{:ok, member} =
Member
@ -58,7 +62,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
last_name: "Anderson",
email: "alice@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Create custom field
{:ok, field} =
@ -68,7 +72,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
value_type: :string,
show_in_overview: true
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Create very long value (but within limits)
long_value = String.duplicate("A", 500)
@ -80,7 +84,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
custom_field_id: field.id,
value: %{"_union_type" => "string", "_union_value" => long_value}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
@ -91,6 +95,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
end
test "handles multiple custom fields with show_in_overview correctly", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test member
{:ok, member} =
Member
@ -99,7 +105,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
last_name: "Anderson",
email: "alice@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Create multiple custom fields with show_in_overview: true
{:ok, field1} =
@ -109,7 +115,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
value_type: :string,
show_in_overview: true
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, field2} =
CustomField
@ -118,7 +124,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
value_type: :string,
show_in_overview: true
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, field3} =
CustomField
@ -127,7 +133,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
value_type: :string,
show_in_overview: true
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Create values for all fields
{:ok, _cfv1} =
@ -137,7 +143,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
custom_field_id: field1.id,
value: %{"_union_type" => "string", "_union_value" => "Value1"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, _cfv2} =
Mv.Membership.CustomFieldValue
@ -146,7 +152,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
custom_field_id: field2.id,
value: %{"_union_type" => "string", "_union_value" => "Value2"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, _cfv3} =
Mv.Membership.CustomFieldValue
@ -155,7 +161,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
custom_field_id: field3.id,
value: %{"_union_type" => "string", "_union_value" => "Value3"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")

View file

@ -16,6 +16,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test members
{:ok, member1} =
Member
@ -24,7 +26,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
last_name: "Anderson",
email: "alice@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, member2} =
Member
@ -33,7 +35,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
last_name: "Brown",
email: "bob@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, member3} =
Member
@ -42,7 +44,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
last_name: "Clark",
email: "charlie@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Create custom field with show_in_overview: true
{:ok, field_string} =
@ -52,7 +54,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
value_type: :string,
show_in_overview: true
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, field_integer} =
CustomField
@ -61,7 +63,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
value_type: :integer,
show_in_overview: true
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Create custom field values
{:ok, _cfv1} =
@ -71,7 +73,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
custom_field_id: field_string.id,
value: %{"_union_type" => "string", "_union_value" => "A001"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, _cfv2} =
CustomFieldValue
@ -80,7 +82,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
custom_field_id: field_string.id,
value: %{"_union_type" => "string", "_union_value" => "C003"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, _cfv3} =
CustomFieldValue
@ -89,7 +91,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
custom_field_id: field_string.id,
value: %{"_union_type" => "string", "_union_value" => "B002"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, _cfv4} =
CustomFieldValue
@ -98,7 +100,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
custom_field_id: field_integer.id,
value: %{"_union_type" => "integer", "_union_value" => 10}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, _cfv5} =
CustomFieldValue
@ -107,7 +109,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
custom_field_id: field_integer.id,
value: %{"_union_type" => "integer", "_union_value" => 30}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, _cfv6} =
CustomFieldValue
@ -116,7 +118,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
custom_field_id: field_integer.id,
value: %{"_union_type" => "integer", "_union_value" => 20}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
%{
member1: member1,
@ -236,6 +238,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
end
test "NULL values and empty strings are always sorted last (ASC)", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create additional members with NULL and empty string values
{:ok, member_with_value} =
Member
@ -244,7 +248,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
last_name: "Test",
email: "withvalue@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, member_with_empty} =
Member
@ -253,7 +257,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
last_name: "Test",
email: "withempty@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, member_with_null} =
Member
@ -262,7 +266,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
last_name: "Test",
email: "withnull@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, member_with_another_value} =
Member
@ -271,7 +275,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
last_name: "Test",
email: "another@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Create custom field
{:ok, field} =
@ -281,7 +285,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
value_type: :string,
show_in_overview: true
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Create values: one with actual value, one with empty string, one with NULL (no value), another with value
{:ok, _cfv1} =
@ -291,7 +295,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
custom_field_id: field.id,
value: %{"_union_type" => "string", "_union_value" => "Zebra"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, _cfv2} =
CustomFieldValue
@ -300,7 +304,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
custom_field_id: field.id,
value: %{"_union_type" => "string", "_union_value" => ""}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# member_with_null has no custom field value (NULL)
@ -311,7 +315,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
custom_field_id: field.id,
value: %{"_union_type" => "string", "_union_value" => "Apple"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
conn = conn_with_oidc_user(conn)
@ -347,6 +351,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
end
test "NULL values and empty strings are always sorted last (DESC)", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create additional members with NULL and empty string values
{:ok, member_with_value} =
Member
@ -355,7 +361,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
last_name: "Test",
email: "withvalue@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, member_with_empty} =
Member
@ -364,7 +370,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
last_name: "Test",
email: "withempty@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, member_with_null} =
Member
@ -373,7 +379,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
last_name: "Test",
email: "withnull@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, member_with_another_value} =
Member
@ -382,7 +388,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
last_name: "Test",
email: "another@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Create custom field
{:ok, field} =
@ -392,7 +398,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
value_type: :string,
show_in_overview: true
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Create values: one with actual value, one with empty string, one with NULL (no value), another with value
{:ok, _cfv1} =
@ -402,7 +408,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
custom_field_id: field.id,
value: %{"_union_type" => "string", "_union_value" => "Apple"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, _cfv2} =
CustomFieldValue
@ -411,7 +417,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
custom_field_id: field.id,
value: %{"_union_type" => "string", "_union_value" => ""}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# member_with_null has no custom field value (NULL)
@ -422,7 +428,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
custom_field_id: field.id,
value: %{"_union_type" => "string", "_union_value" => "Zebra"}
})
|> Ash.create()
|> Ash.create(actor: system_actor)
conn = conn_with_oidc_user(conn)

View file

@ -19,6 +19,8 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test members
{:ok, member1} =
Member
@ -29,7 +31,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
street: "Main St",
city: "Berlin"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, member2} =
Member
@ -40,7 +42,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
street: "Second St",
city: "Hamburg"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Create custom field
{:ok, custom_field} =
@ -50,7 +52,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
value_type: :string,
show_in_overview: true
})
|> Ash.create()
|> Ash.create(actor: system_actor)
# Create custom field values
{:ok, _cfv1} =
@ -60,7 +62,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
custom_field_id: custom_field.id,
value: "M001"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, _cfv2} =
CustomFieldValue
@ -69,7 +71,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
custom_field_id: custom_field.id,
value: "M002"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
%{
member1: member1,

View file

@ -6,6 +6,8 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
alias Mv.Membership.Member
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, member1} =
Member
|> Ash.Changeset.for_create(:create_member, %{
@ -18,7 +20,7 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
city: "Berlin",
join_date: ~D[2020-01-15]
})
|> Ash.create()
|> Ash.create(actor: system_actor)
{:ok, member2} =
Member
@ -27,7 +29,7 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
last_name: "Brown",
email: "bob@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
%{
member1: member1,

View file

@ -14,6 +14,8 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
# Helper to create a membership fee type
defp create_fee_type(attrs) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
@ -24,11 +26,13 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
|> 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",
@ -39,18 +43,20 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
|> Ash.create!(actor: system_actor)
end
# Helper to create a cycle
defp create_cycle(member, fee_type, attrs) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Delete any auto-generated cycles first to avoid conflicts
existing_cycles =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
|> Ash.read!(actor: system_actor)
Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle) end)
Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle, actor: system_actor) end)
default_attrs = %{
cycle_start: ~D[2023-01-01],
@ -64,7 +70,7 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
|> Ash.create!(actor: system_actor)
end
describe "status column display" do
@ -172,16 +178,18 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
member2 = create_member(%{first_name: "PaidMember", membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Verify cycles exist in database
cycles1 =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member1.id)
|> Ash.read!()
|> Ash.read!(actor: system_actor)
cycles2 =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member2.id)
|> Ash.read!()
|> Ash.read!(actor: system_actor)
refute Enum.empty?(cycles1)
refute Enum.empty?(cycles2)
@ -206,16 +214,18 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
member2 = create_member(%{first_name: "PaidCurrent", membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :paid})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Verify cycles exist in database
cycles1 =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member1.id)
|> Ash.read!()
|> Ash.read!(actor: system_actor)
cycles2 =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member2.id)
|> Ash.read!()
|> Ash.read!(actor: system_actor)
refute Enum.empty?(cycles1)
refute Enum.empty?(cycles2)

View file

@ -266,13 +266,18 @@ defmodule MvWeb.MemberLive.IndexTest do
end
test "can delete a member without error", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create a test member first
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "Test",
last_name: "User",
email: "test@example.com"
})
Mv.Membership.create_member(
%{
first_name: "Test",
last_name: "User",
email: "test@example.com"
},
actor: system_actor
)
conn = conn_with_oidc_user(conn)
{:ok, index_view, _html} = live(conn, "/members")
@ -294,27 +299,38 @@ defmodule MvWeb.MemberLive.IndexTest do
describe "copy_emails feature" do
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test members
{:ok, member1} =
Mv.Membership.create_member(%{
first_name: "Max",
last_name: "Mustermann",
email: "max@example.com"
})
Mv.Membership.create_member(
%{
first_name: "Max",
last_name: "Mustermann",
email: "max@example.com"
},
actor: system_actor
)
{:ok, member2} =
Mv.Membership.create_member(%{
first_name: "Erika",
last_name: "Musterfrau",
email: "erika@example.com"
})
Mv.Membership.create_member(
%{
first_name: "Erika",
last_name: "Musterfrau",
email: "erika@example.com"
},
actor: system_actor
)
{:ok, member3} =
Mv.Membership.create_member(%{
first_name: "Hans",
last_name: "Müller-Lüdenscheidt",
email: "hans@example.com"
})
Mv.Membership.create_member(
%{
first_name: "Hans",
last_name: "Müller-Lüdenscheidt",
email: "hans@example.com"
},
actor: system_actor
)
%{member1: member1, member2: member2, member3: member3}
end
@ -394,7 +410,8 @@ defmodule MvWeb.MemberLive.IndexTest do
render_click(view, "select_member", %{"id" => member1.id})
# Delete the member from the database
Ash.destroy!(member1)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
Ash.destroy!(member1, actor: system_actor)
# Trigger copy_emails event directly - selection still contains the deleted ID
# but the member is no longer in @members list after reload
@ -434,12 +451,17 @@ defmodule MvWeb.MemberLive.IndexTest do
conn = conn_with_oidc_user(conn)
# Create a member with known data
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, test_member} =
Mv.Membership.create_member(%{
first_name: "Test",
last_name: "Format",
email: "test.format@example.com"
})
Mv.Membership.create_member(
%{
first_name: "Test",
last_name: "Format",
email: "test.format@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/members")
@ -500,8 +522,26 @@ defmodule MvWeb.MemberLive.IndexTest do
end
describe "cycle status filter" do
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
# Helper to create a membership fee type
defp create_fee_type(attrs, actor) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!(actor: actor)
end
# Helper to create a member
defp create_member(attrs) do
defp create_member(attrs, actor) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
@ -512,32 +552,74 @@ defmodule MvWeb.MemberLive.IndexTest do
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
|> Ash.create!(actor: actor)
end
# Helper to create a cycle
defp create_cycle(member, fee_type, attrs, actor) do
# Delete any auto-generated cycles first to avoid conflicts
existing_cycles =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!(actor: actor)
Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle, actor: actor) end)
default_attrs = %{
cycle_start: ~D[2023-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :unpaid
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!(actor: actor)
end
test "filter shows only members with paid status in last cycle", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
fee_type = create_fee_type(%{interval: :yearly})
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
today = Date.utc_today()
last_year_start = Date.new!(today.year - 1, 1, 1)
# Member with paid last cycle
paid_member =
create_member(%{
first_name: "PaidLast",
membership_fee_type_id: fee_type.id
})
create_member(
%{
first_name: "PaidLast",
membership_fee_type_id: fee_type.id
},
system_actor
)
create_cycle(paid_member, fee_type, %{cycle_start: last_year_start, status: :paid})
create_cycle(
paid_member,
fee_type,
%{cycle_start: last_year_start, status: :paid},
system_actor
)
# Member with unpaid last cycle
unpaid_member =
create_member(%{
first_name: "UnpaidLast",
membership_fee_type_id: fee_type.id
})
create_member(
%{
first_name: "UnpaidLast",
membership_fee_type_id: fee_type.id
},
system_actor
)
create_cycle(unpaid_member, fee_type, %{cycle_start: last_year_start, status: :unpaid})
create_cycle(
unpaid_member,
fee_type,
%{cycle_start: last_year_start, status: :unpaid},
system_actor
)
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid")
@ -546,28 +628,45 @@ defmodule MvWeb.MemberLive.IndexTest do
end
test "filter shows only members with unpaid status in last cycle", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
fee_type = create_fee_type(%{interval: :yearly})
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
today = Date.utc_today()
last_year_start = Date.new!(today.year - 1, 1, 1)
# Member with paid last cycle
paid_member =
create_member(%{
first_name: "PaidLast",
membership_fee_type_id: fee_type.id
})
create_member(
%{
first_name: "PaidLast",
membership_fee_type_id: fee_type.id
},
system_actor
)
create_cycle(paid_member, fee_type, %{cycle_start: last_year_start, status: :paid})
create_cycle(
paid_member,
fee_type,
%{cycle_start: last_year_start, status: :paid},
system_actor
)
# Member with unpaid last cycle
unpaid_member =
create_member(%{
first_name: "UnpaidLast",
membership_fee_type_id: fee_type.id
})
create_member(
%{
first_name: "UnpaidLast",
membership_fee_type_id: fee_type.id
},
system_actor
)
create_cycle(unpaid_member, fee_type, %{cycle_start: last_year_start, status: :unpaid})
create_cycle(
unpaid_member,
fee_type,
%{cycle_start: last_year_start, status: :unpaid},
system_actor
)
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=unpaid")
@ -576,28 +675,45 @@ defmodule MvWeb.MemberLive.IndexTest do
end
test "filter shows only members with paid status in current cycle", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
fee_type = create_fee_type(%{interval: :yearly})
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
today = Date.utc_today()
current_year_start = Date.new!(today.year, 1, 1)
# Member with paid current cycle
paid_member =
create_member(%{
first_name: "PaidCurrent",
membership_fee_type_id: fee_type.id
})
create_member(
%{
first_name: "PaidCurrent",
membership_fee_type_id: fee_type.id
},
system_actor
)
create_cycle(paid_member, fee_type, %{cycle_start: current_year_start, status: :paid})
create_cycle(
paid_member,
fee_type,
%{cycle_start: current_year_start, status: :paid},
system_actor
)
# Member with unpaid current cycle
unpaid_member =
create_member(%{
first_name: "UnpaidCurrent",
membership_fee_type_id: fee_type.id
})
create_member(
%{
first_name: "UnpaidCurrent",
membership_fee_type_id: fee_type.id
},
system_actor
)
create_cycle(unpaid_member, fee_type, %{cycle_start: current_year_start, status: :unpaid})
create_cycle(
unpaid_member,
fee_type,
%{cycle_start: current_year_start, status: :unpaid},
system_actor
)
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid&show_current_cycle=true")
@ -606,28 +722,45 @@ defmodule MvWeb.MemberLive.IndexTest do
end
test "filter shows only members with unpaid status in current cycle", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
fee_type = create_fee_type(%{interval: :yearly})
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
today = Date.utc_today()
current_year_start = Date.new!(today.year, 1, 1)
# Member with paid current cycle
paid_member =
create_member(%{
first_name: "PaidCurrent",
membership_fee_type_id: fee_type.id
})
create_member(
%{
first_name: "PaidCurrent",
membership_fee_type_id: fee_type.id
},
system_actor
)
create_cycle(paid_member, fee_type, %{cycle_start: current_year_start, status: :paid})
create_cycle(
paid_member,
fee_type,
%{cycle_start: current_year_start, status: :paid},
system_actor
)
# Member with unpaid current cycle
unpaid_member =
create_member(%{
first_name: "UnpaidCurrent",
membership_fee_type_id: fee_type.id
})
create_member(
%{
first_name: "UnpaidCurrent",
membership_fee_type_id: fee_type.id
},
system_actor
)
create_cycle(unpaid_member, fee_type, %{cycle_start: current_year_start, status: :unpaid})
create_cycle(
unpaid_member,
fee_type,
%{cycle_start: current_year_start, status: :unpaid},
system_actor
)
{:ok, _view, html} =
live(conn, "/members?cycle_status_filter=unpaid&show_current_cycle=true")

View file

@ -14,6 +14,8 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
# Helper to create a membership fee type
defp create_fee_type(attrs) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
@ -24,11 +26,13 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
|> 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",
@ -39,7 +43,7 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
|> Ash.create!(actor: system_actor)
end
describe "end-to-end workflows" do
@ -75,7 +79,13 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
|> render_click()
# Verify status changed
updated_cycle = Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id))
system_actor = Mv.Helpers.SystemActor.get_system_actor()
updated_cycle =
Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id),
actor: system_actor
)
assert updated_cycle.status == :paid
end
end
@ -115,13 +125,14 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
fee_type = create_fee_type(%{interval: :yearly})
# Update settings
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, settings} = Mv.Membership.get_settings()
settings
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
default_membership_fee_type_id: fee_type.id
})
|> Ash.update!()
|> Ash.update!(actor: system_actor)
# Create new member
{:ok, view, _html} = live(conn, "/members/new")
@ -138,10 +149,12 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
|> render_submit()
# Verify member got default type
system_actor = Mv.Helpers.SystemActor.get_system_actor()
member =
Member
|> Ash.Query.filter(email == ^form_data["member[email]"])
|> Ash.read_one!()
|> Ash.read_one!(actor: system_actor)
assert member.membership_fee_type_id == fee_type.id
end
@ -150,6 +163,8 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
cycle =
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, %{
@ -159,7 +174,7 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
membership_fee_type_id: fee_type.id,
status: :unpaid
})
|> Ash.create!()
|> Ash.create!(actor: system_actor)
{:ok, view, _html} = live(conn, "/members/#{member.id}")
@ -187,6 +202,8 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
cycle =
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, %{
@ -196,7 +213,7 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
membership_fee_type_id: fee_type.id,
status: :unpaid
})
|> Ash.create!()
|> Ash.create!(actor: system_actor)
{:ok, view, _html} = live(conn, "/members/#{member.id}")
@ -216,7 +233,13 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
|> render_submit()
# Verify amount updated
updated_cycle = Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id))
system_actor = Mv.Helpers.SystemActor.get_system_actor()
updated_cycle =
Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id),
actor: system_actor
)
assert updated_cycle.amount == Decimal.new("75.00")
end
end

View file

@ -14,6 +14,8 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
# Helper to create a membership fee type
defp create_fee_type(attrs) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
@ -24,11 +26,13 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
|> 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",
@ -39,18 +43,20 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
|> Ash.create!(actor: system_actor)
end
# Helper to create a cycle
defp create_cycle(member, fee_type, attrs) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Delete any auto-generated cycles first to avoid conflicts
existing_cycles =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
|> Ash.read!(actor: system_actor)
Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle) end)
Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle, actor: system_actor) end)
default_attrs = %{
cycle_start: ~D[2023-01-01],
@ -64,7 +70,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
|> Ash.create!(actor: system_actor)
end
describe "cycles table display" do
@ -161,7 +167,13 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|> render_click()
# Verify cycle is now paid
updated_cycle = Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id))
system_actor = Mv.Helpers.SystemActor.get_system_actor()
updated_cycle =
Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id),
actor: system_actor
)
assert updated_cycle.status == :paid
end
@ -186,7 +198,13 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|> render_click()
# Verify cycle is now suspended
updated_cycle = Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id))
system_actor = Mv.Helpers.SystemActor.get_system_actor()
updated_cycle =
Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id),
actor: system_actor
)
assert updated_cycle.status == :suspended
end
@ -211,7 +229,13 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|> render_click()
# Verify cycle is now unpaid
updated_cycle = Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id))
system_actor = Mv.Helpers.SystemActor.get_system_actor()
updated_cycle =
Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id),
actor: system_actor
)
assert updated_cycle.status == :unpaid
end
end

View file

@ -21,6 +21,8 @@ defmodule MvWeb.MemberLive.ShowTest do
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test member
{:ok, member} =
Member
@ -29,15 +31,16 @@ defmodule MvWeb.MemberLive.ShowTest do
last_name: "Anderson",
email: "alice@example.com"
})
|> Ash.create()
|> Ash.create(actor: system_actor)
%{member: member}
%{member: member, actor: system_actor}
end
describe "custom fields section visibility (Issue #282)" do
test "displays Custom Fields section even when member has no custom field values", %{
conn: conn,
member: member
member: member,
actor: actor
} do
# Create a custom field but no value for the member
{:ok, custom_field} =
@ -46,7 +49,7 @@ defmodule MvWeb.MemberLive.ShowTest do
name: "phone_mobile",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/members/#{member}")
@ -63,7 +66,8 @@ defmodule MvWeb.MemberLive.ShowTest do
test "displays Custom Fields section with multiple custom fields, some without values", %{
conn: conn,
member: member
member: member,
actor: actor
} do
# Create multiple custom fields
{:ok, field1} =
@ -72,7 +76,7 @@ defmodule MvWeb.MemberLive.ShowTest do
name: "phone_mobile",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
{:ok, field2} =
CustomField
@ -80,7 +84,7 @@ defmodule MvWeb.MemberLive.ShowTest do
name: "membership_number",
value_type: :integer
})
|> Ash.create()
|> Ash.create(actor: actor)
# Create value only for first field
{:ok, _cfv} =
@ -90,7 +94,7 @@ defmodule MvWeb.MemberLive.ShowTest do
custom_field_id: field1.id,
value: %{"_union_type" => "string", "_union_value" => "+49123456789"}
})
|> Ash.create()
|> Ash.create(actor: actor)
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/members/#{member}")
@ -111,18 +115,19 @@ defmodule MvWeb.MemberLive.ShowTest do
test "does not display Custom Fields section when no custom fields exist", %{
conn: conn,
member: member
member: member,
actor: actor
} do
# Ensure no custom fields exist for this test
# This ensures test isolation even if previous tests created custom fields
existing_custom_fields = Ash.read!(CustomField)
existing_custom_fields = Ash.read!(CustomField, actor: actor)
for cf <- existing_custom_fields do
Ash.destroy!(cf)
Ash.destroy!(cf, actor: actor)
end
# Verify no custom fields exist
assert Ash.read!(CustomField) == []
assert Ash.read!(CustomField, actor: actor) == []
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/members/#{member}")
@ -133,14 +138,14 @@ defmodule MvWeb.MemberLive.ShowTest do
end
describe "custom field value formatting" do
test "formats string custom field values", %{conn: conn, member: member} do
test "formats string custom field values", %{conn: conn, member: member, actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "phone_mobile",
value_type: :string
})
|> Ash.create()
|> Ash.create(actor: actor)
{:ok, _cfv} =
CustomFieldValue
@ -149,7 +154,7 @@ defmodule MvWeb.MemberLive.ShowTest do
custom_field_id: custom_field.id,
value: %{"_union_type" => "string", "_union_value" => "+49123456789"}
})
|> Ash.create()
|> Ash.create(actor: actor)
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/members/#{member}")
@ -157,14 +162,18 @@ defmodule MvWeb.MemberLive.ShowTest do
assert html =~ "+49123456789"
end
test "formats email custom field values as mailto links", %{conn: conn, member: member} do
test "formats email custom field values as mailto links", %{
conn: conn,
member: member,
actor: actor
} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "private_email",
value_type: :email
})
|> Ash.create()
|> Ash.create(actor: actor)
{:ok, _cfv} =
CustomFieldValue
@ -173,7 +182,7 @@ defmodule MvWeb.MemberLive.ShowTest do
custom_field_id: custom_field.id,
value: %{"_union_type" => "email", "_union_value" => "private@example.com"}
})
|> Ash.create()
|> Ash.create(actor: actor)
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/members/#{member}")

View file

@ -70,12 +70,17 @@ defmodule MvWeb.UserLive.FormMemberDropdownTest do
test "links user and member with identical email successfully", %{conn: conn} do
conn = setup_admin_conn(conn)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, member} =
Membership.create_member(%{
first_name: "David",
last_name: "Miller",
email: "david@example.com"
})
Membership.create_member(
%{
first_name: "David",
last_name: "Miller",
email: "david@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, ~p"/users/new")
@ -106,12 +111,17 @@ defmodule MvWeb.UserLive.FormMemberDropdownTest do
test "shows member with same email in dropdown", %{conn: conn} do
conn = setup_admin_conn(conn)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, _member} =
Membership.create_member(%{
first_name: "Emma",
last_name: "Davis",
email: "emma@example.com"
})
Membership.create_member(
%{
first_name: "Emma",
last_name: "Davis",
email: "emma@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, ~p"/users/new")
@ -135,13 +145,18 @@ defmodule MvWeb.UserLive.FormMemberDropdownTest do
# Helper functions
defp create_unlinked_members(count) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
for i <- 1..count do
{:ok, member} =
Membership.create_member(%{
first_name: "FirstName#{i}",
last_name: "LastName#{i}",
email: "member#{i}@example.com"
})
Membership.create_member(
%{
first_name: "FirstName#{i}",
last_name: "LastName#{i}",
email: "member#{i}@example.com"
},
actor: system_actor
)
member
end

View file

@ -18,14 +18,18 @@ defmodule MvWeb.UserLive.FormMemberSearchTest do
describe "fuzzy search" do
test "finds member with exact name", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = setup_admin_conn(conn)
{:ok, _member} =
Membership.create_member(%{
first_name: "Jonathan",
last_name: "Smith",
email: "jonathan.smith@example.com"
})
Membership.create_member(
%{
first_name: "Jonathan",
last_name: "Smith",
email: "jonathan.smith@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, ~p"/users/new")
@ -41,14 +45,18 @@ defmodule MvWeb.UserLive.FormMemberSearchTest do
end
test "finds member with typo (Jon finds Jonathan)", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = setup_admin_conn(conn)
{:ok, _member} =
Membership.create_member(%{
first_name: "Jonathan",
last_name: "Smith",
email: "jonathan.smith@example.com"
})
Membership.create_member(
%{
first_name: "Jonathan",
last_name: "Smith",
email: "jonathan.smith@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, ~p"/users/new")
@ -65,14 +73,18 @@ defmodule MvWeb.UserLive.FormMemberSearchTest do
end
test "finds member with partial substring", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = setup_admin_conn(conn)
{:ok, _member} =
Membership.create_member(%{
first_name: "Alexander",
last_name: "Williams",
email: "alex@example.com"
})
Membership.create_member(
%{
first_name: "Alexander",
last_name: "Williams",
email: "alex@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, ~p"/users/new")
@ -87,14 +99,18 @@ defmodule MvWeb.UserLive.FormMemberSearchTest do
end
test "shows partial match with similar names", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = setup_admin_conn(conn)
{:ok, _member} =
Membership.create_member(%{
first_name: "Johnny",
last_name: "Doeson",
email: "johnny@example.com"
})
Membership.create_member(
%{
first_name: "Johnny",
last_name: "Doeson",
email: "johnny@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, ~p"/users/new")

View file

@ -19,14 +19,18 @@ defmodule MvWeb.UserLive.FormMemberSelectionTest do
describe "member selection" do
test "input field shows selected member name", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = setup_admin_conn(conn)
{:ok, member} =
Membership.create_member(%{
first_name: "Alice",
last_name: "Johnson",
email: "alice@example.com"
})
Membership.create_member(
%{
first_name: "Alice",
last_name: "Johnson",
email: "alice@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, ~p"/users/new")
@ -47,14 +51,18 @@ defmodule MvWeb.UserLive.FormMemberSelectionTest do
end
test "confirmation box appears", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = setup_admin_conn(conn)
{:ok, member} =
Membership.create_member(%{
first_name: "Bob",
last_name: "Williams",
email: "bob@example.com"
})
Membership.create_member(
%{
first_name: "Bob",
last_name: "Williams",
email: "bob@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, ~p"/users/new")
@ -77,14 +85,18 @@ defmodule MvWeb.UserLive.FormMemberSelectionTest do
end
test "hidden input stores member ID", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = setup_admin_conn(conn)
{:ok, member} =
Membership.create_member(%{
first_name: "Charlie",
last_name: "Brown",
email: "charlie@example.com"
})
Membership.create_member(
%{
first_name: "Charlie",
last_name: "Brown",
email: "charlie@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, ~p"/users/new")
@ -105,20 +117,27 @@ defmodule MvWeb.UserLive.FormMemberSelectionTest do
describe "unlink workflow" do
test "unlink hides dropdown", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = setup_admin_conn(conn)
# Create user with linked member
{:ok, member} =
Membership.create_member(%{
first_name: "Frank",
last_name: "Wilson",
email: "frank@example.com"
})
Membership.create_member(
%{
first_name: "Frank",
last_name: "Wilson",
email: "frank@example.com"
},
actor: system_actor
)
{:ok, user} =
Accounts.create_user(%{
email: "frank@example.com",
member: %{id: member.id}
})
Accounts.create_user(
%{
email: "frank@example.com",
member: %{id: member.id}
},
actor: system_actor
)
{:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit")
@ -134,20 +153,27 @@ defmodule MvWeb.UserLive.FormMemberSelectionTest do
end
test "unlink shows warning", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = setup_admin_conn(conn)
# Create user with linked member
{:ok, member} =
Membership.create_member(%{
first_name: "Grace",
last_name: "Taylor",
email: "grace@example.com"
})
Membership.create_member(
%{
first_name: "Grace",
last_name: "Taylor",
email: "grace@example.com"
},
actor: system_actor
)
{:ok, user} =
Accounts.create_user(%{
email: "grace@example.com",
member: %{id: member.id}
})
Accounts.create_user(
%{
email: "grace@example.com",
member: %{id: member.id}
},
actor: system_actor
)
{:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit")
@ -164,20 +190,27 @@ defmodule MvWeb.UserLive.FormMemberSelectionTest do
end
test "unlink disables input", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = setup_admin_conn(conn)
# Create user with linked member
{:ok, member} =
Membership.create_member(%{
first_name: "Henry",
last_name: "Anderson",
email: "henry@example.com"
})
Membership.create_member(
%{
first_name: "Henry",
last_name: "Anderson",
email: "henry@example.com"
},
actor: system_actor
)
{:ok, user} =
Accounts.create_user(%{
email: "henry@example.com",
member: %{id: member.id}
})
Accounts.create_user(
%{
email: "henry@example.com",
member: %{id: member.id}
},
actor: system_actor
)
{:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit")
@ -193,20 +226,27 @@ defmodule MvWeb.UserLive.FormMemberSelectionTest do
end
test "save re-enables member selection", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = setup_admin_conn(conn)
# Create user with linked member
{:ok, member} =
Membership.create_member(%{
first_name: "Isabel",
last_name: "Martinez",
email: "isabel@example.com"
})
Membership.create_member(
%{
first_name: "Isabel",
last_name: "Martinez",
email: "isabel@example.com"
},
actor: system_actor
)
{:ok, user} =
Accounts.create_user(%{
email: "isabel@example.com",
member: %{id: member.id}
})
Accounts.create_user(
%{
email: "isabel@example.com",
member: %{id: member.id}
},
actor: system_actor
)
{:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit")

View file

@ -75,11 +75,14 @@ defmodule MvWeb.UserLive.FormTest do
|> form("#user-form", user: %{email: "storetest@example.com"})
|> render_submit()
system_actor = Mv.Helpers.SystemActor.get_system_actor()
user =
Ash.get!(
Mv.Accounts.User,
[email: Ash.CiString.new("storetest@example.com")],
domain: Mv.Accounts
domain: Mv.Accounts,
actor: system_actor
)
assert to_string(user.email) == "storetest@example.com"
@ -101,11 +104,14 @@ defmodule MvWeb.UserLive.FormTest do
)
|> render_submit()
system_actor = Mv.Helpers.SystemActor.get_system_actor()
user =
Ash.get!(
Mv.Accounts.User,
[email: Ash.CiString.new("passwordstoretest@example.com")],
domain: Mv.Accounts
domain: Mv.Accounts,
actor: system_actor
)
assert user.hashed_password != nil
@ -181,7 +187,8 @@ defmodule MvWeb.UserLive.FormTest do
assert_redirected(view, "/users")
updated_user = Ash.reload!(user, domain: Mv.Accounts)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
updated_user = Ash.reload!(user, domain: Mv.Accounts, actor: system_actor)
assert to_string(updated_user.email) == "new@example.com"
assert updated_user.hashed_password == original_password
end
@ -204,7 +211,8 @@ defmodule MvWeb.UserLive.FormTest do
assert_redirected(view, "/users")
updated_user = Ash.reload!(user, domain: Mv.Accounts)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
updated_user = Ash.reload!(user, domain: Mv.Accounts, actor: system_actor)
assert updated_user.hashed_password != original_password
assert String.starts_with?(updated_user.hashed_password, "$2b$")
end
@ -285,17 +293,24 @@ defmodule MvWeb.UserLive.FormTest do
describe "member linking - display" do
test "shows linked member with unlink button when user has member", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create member
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "john@example.com"
})
Mv.Membership.create_member(
%{
first_name: "John",
last_name: "Doe",
email: "john@example.com"
},
actor: system_actor
)
# 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}})
{:ok, _updated_user} =
Mv.Accounts.update_user(user, %{member: %{id: member.id}}, actor: system_actor)
# Load form
{:ok, view, html} = setup_live_view(conn, "/users/#{user.id}/edit")
@ -322,13 +337,18 @@ defmodule MvWeb.UserLive.FormTest do
describe "member linking - workflow" do
test "selecting member and saving links member to user", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create unlinked member
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "Jane",
last_name: "Smith",
email: "jane@example.com"
})
Mv.Membership.create_member(
%{
first_name: "Jane",
last_name: "Smith",
email: "jane@example.com"
},
actor: system_actor
)
# Create user without member
user = create_test_user(%{email: "user@example.com"})
@ -345,22 +365,35 @@ defmodule MvWeb.UserLive.FormTest do
assert_redirected(view, "/users")
# Verify member is linked
updated_user = Ash.get!(Mv.Accounts.User, user.id, domain: Mv.Accounts, load: [:member])
system_actor = Mv.Helpers.SystemActor.get_system_actor()
updated_user =
Ash.get!(Mv.Accounts.User, user.id,
domain: Mv.Accounts,
actor: system_actor,
load: [:member]
)
assert updated_user.member.id == member.id
end
test "unlinking member and saving removes member from user", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create member
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "Bob",
last_name: "Wilson",
email: "bob@example.com"
})
Mv.Membership.create_member(
%{
first_name: "Bob",
last_name: "Wilson",
email: "bob@example.com"
},
actor: system_actor
)
# Create user linked to member
user = create_test_user(%{email: "user@example.com"})
{:ok, _} = Mv.Accounts.update_user(user, %{member: %{id: member.id}})
{:ok, _} = Mv.Accounts.update_user(user, %{member: %{id: member.id}}, actor: system_actor)
{:ok, view, _html} = setup_live_view(conn, "/users/#{user.id}/edit")
@ -375,7 +408,15 @@ defmodule MvWeb.UserLive.FormTest do
assert_redirected(view, "/users")
# Verify member is unlinked
updated_user = Ash.get!(Mv.Accounts.User, user.id, domain: Mv.Accounts, load: [:member])
system_actor = Mv.Helpers.SystemActor.get_system_actor()
updated_user =
Ash.get!(Mv.Accounts.User, user.id,
domain: Mv.Accounts,
actor: system_actor,
load: [:member]
)
assert is_nil(updated_user.member)
end
end

View file

@ -407,17 +407,24 @@ defmodule MvWeb.UserLive.IndexTest do
describe "member linking display" do
test "displays linked member name in user list", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create member
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "Alice",
last_name: "Johnson",
email: "alice@example.com"
})
Mv.Membership.create_member(
%{
first_name: "Alice",
last_name: "Johnson",
email: "alice@example.com"
},
actor: system_actor
)
# 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}})
{:ok, _updated_user} =
Mv.Accounts.update_user(user, %{member: %{id: member.id}}, actor: system_actor)
# Create another user without member
_unlinked_user = create_test_user(%{email: "unlinked@example.com"})

View file

@ -3,37 +3,42 @@ defmodule Mv.SeedsTest do
require Ash.Query
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "Seeds script" do
test "runs successfully without errors" do
test "runs successfully without errors", %{actor: actor} do
# Run the seeds script - should not raise any errors
assert Code.eval_file("priv/repo/seeds.exs")
# Basic smoke test: ensure some data was created
{:ok, users} = Ash.read(Mv.Accounts.User)
{:ok, members} = Ash.read(Mv.Membership.Member)
{:ok, custom_fields} = Ash.read(Mv.Membership.CustomField)
{:ok, users} = Ash.read(Mv.Accounts.User, actor: actor)
{:ok, members} = Ash.read(Mv.Membership.Member, actor: actor)
{:ok, custom_fields} = Ash.read(Mv.Membership.CustomField, actor: actor)
assert not Enum.empty?(users), "Seeds should create at least one user"
assert not Enum.empty?(members), "Seeds should create at least one member"
assert not Enum.empty?(custom_fields), "Seeds should create at least one custom field"
end
test "can be run multiple times (idempotent)" do
test "can be run multiple times (idempotent)", %{actor: actor} do
# Run seeds first time
assert Code.eval_file("priv/repo/seeds.exs")
# Count records
{:ok, users_count_1} = Ash.read(Mv.Accounts.User)
{:ok, members_count_1} = Ash.read(Mv.Membership.Member)
{:ok, custom_fields_count_1} = Ash.read(Mv.Membership.CustomField)
{:ok, users_count_1} = Ash.read(Mv.Accounts.User, actor: actor)
{:ok, members_count_1} = Ash.read(Mv.Membership.Member, actor: actor)
{:ok, custom_fields_count_1} = Ash.read(Mv.Membership.CustomField, actor: actor)
# Run seeds second time - should not raise errors
assert Code.eval_file("priv/repo/seeds.exs")
# Count records again - should be the same (upsert, not duplicate)
{:ok, users_count_2} = Ash.read(Mv.Accounts.User)
{:ok, members_count_2} = Ash.read(Mv.Membership.Member)
{:ok, custom_fields_count_2} = Ash.read(Mv.Membership.CustomField)
{:ok, users_count_2} = Ash.read(Mv.Accounts.User, actor: actor)
{:ok, members_count_2} = Ash.read(Mv.Membership.Member, actor: actor)
{:ok, custom_fields_count_2} = Ash.read(Mv.Membership.CustomField, actor: actor)
assert length(users_count_1) == length(users_count_2),
"Users count should remain same after re-running seeds"
@ -45,12 +50,12 @@ defmodule Mv.SeedsTest do
"CustomFields count should remain same after re-running seeds"
end
test "at least one member has no membership fee type assigned" do
test "at least one member has no membership fee type assigned", %{actor: actor} do
# Run the seeds script
assert Code.eval_file("priv/repo/seeds.exs")
# Get all members
{:ok, members} = Ash.read(Mv.Membership.Member)
{:ok, members} = Ash.read(Mv.Membership.Member, actor: actor)
# At least one member should have no membership_fee_type_id
members_without_fee_type =
@ -60,13 +65,13 @@ defmodule Mv.SeedsTest do
"At least one member should have no membership fee type assigned"
end
test "each membership fee type has at least one member" do
test "each membership fee type has at least one member", %{actor: actor} do
# Run the seeds script
assert Code.eval_file("priv/repo/seeds.exs")
# Get all fee types and members
{:ok, fee_types} = Ash.read(Mv.MembershipFees.MembershipFeeType)
{:ok, members} = Ash.read(Mv.Membership.Member)
{:ok, fee_types} = Ash.read(Mv.MembershipFees.MembershipFeeType, actor: actor)
{:ok, members} = Ash.read(Mv.Membership.Member, actor: actor)
# Group members by fee type (excluding nil)
members_by_fee_type =
@ -83,12 +88,12 @@ defmodule Mv.SeedsTest do
end)
end
test "members with fee types have cycles with various statuses" do
test "members with fee types have cycles with various statuses", %{actor: actor} do
# Run the seeds script
assert Code.eval_file("priv/repo/seeds.exs")
# Get all members with fee types
{:ok, members} = Ash.read(Mv.Membership.Member)
{:ok, members} = Ash.read(Mv.Membership.Member, actor: actor)
members_with_fee_types =
members
@ -104,7 +109,7 @@ defmodule Mv.SeedsTest do
|> Enum.flat_map(fn member ->
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
|> Ash.read!(actor: actor)
end)
|> Enum.map(& &1.status)

View file

@ -115,15 +115,16 @@ defmodule MvWeb.ConnCase do
# Create admin role and assign it
admin_role = Mv.Fixtures.role_fixture("admin")
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, user} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update()
|> Ash.update(actor: system_actor)
# Load role for authorization
user_with_role = Ash.load!(user, :role, domain: Mv.Accounts)
user_with_role = Ash.load!(user, :role, domain: Mv.Accounts, actor: system_actor)
sign_in_user_via_oidc(conn, user_with_role)
end

View file

@ -9,6 +9,8 @@ defmodule Mv.Fixtures do
@doc """
Creates a member with default or custom attributes.
Uses system_actor for authorization to bypass permission checks in tests.
## Parameters
- `attrs` - Map or keyword list of attributes to override defaults
@ -25,13 +27,15 @@ defmodule Mv.Fixtures do
"""
def member_fixture(attrs \\ %{}) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
attrs
|> Enum.into(%{
first_name: "Test",
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com"
})
|> Mv.Membership.create_member()
|> Mv.Membership.create_member(actor: system_actor)
|> case do
{:ok, member} -> member
{:error, error} -> raise "Failed to create member: #{inspect(error)}"
@ -41,6 +45,11 @@ defmodule Mv.Fixtures do
@doc """
Creates a user with default or custom attributes.
Uses system_actor for authorization to bypass permission checks in tests.
Note: create_user action should work via AshAuthentication bypass,
but we use system_actor for consistency and safety.
## Parameters
- `attrs` - Map or keyword list of attributes to override defaults
@ -57,11 +66,13 @@ defmodule Mv.Fixtures do
"""
def user_fixture(attrs \\ %{}) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
attrs
|> Enum.into(%{
email: "user#{System.unique_integer([:positive])}@example.com"
})
|> Mv.Accounts.create_user()
|> Mv.Accounts.create_user(actor: system_actor)
|> case do
{:ok, user} -> user
{:error, error} -> raise "Failed to create user: #{inspect(error)}"
@ -97,6 +108,8 @@ defmodule Mv.Fixtures do
@doc """
Creates a role with a specific permission set.
Uses system_actor for authorization to bypass permission checks in tests.
## Parameters
- `permission_set_name` - The permission set name (e.g., "admin", "read_only", "normal_user", "own_data")
@ -110,13 +123,17 @@ defmodule Mv.Fixtures do
"""
def role_fixture(permission_set_name) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
case Mv.Authorization.create_role(%{
name: role_name,
description: "Test role for #{permission_set_name}",
permission_set_name: permission_set_name
}) do
case Mv.Authorization.create_role(
%{
name: role_name,
description: "Test role for #{permission_set_name}",
permission_set_name: permission_set_name
},
actor: system_actor
) do
{:ok, role} -> role
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
end
@ -140,6 +157,8 @@ defmodule Mv.Fixtures do
"""
def user_with_role_fixture(permission_set_name \\ "admin", user_attrs \\ %{}) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create role with permission set
role = role_fixture(permission_set_name)
@ -149,14 +168,14 @@ defmodule Mv.Fixtures do
|> Enum.into(%{
email: "user#{System.unique_integer([:positive])}@example.com"
})
|> Mv.Accounts.create_user()
|> Mv.Accounts.create_user(actor: system_actor)
# Assign role to user
{:ok, user} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|> Ash.update()
|> Ash.update(actor: system_actor)
# Reload user with role preloaded (critical for authorization!)
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts)