From e72b7ab2e84c443b79e8769a496896d00822a161 Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 23 Jan 2026 20:00:18 +0100 Subject: [PATCH 01/15] Remove NoActor bypass from User and Member policies This removes the NoActor bypass that was masking authorization bugs in tests. All operations now require an explicit actor for authorization. --- lib/accounts/user.ex | 6 ------ lib/membership/member.ex | 17 ++++++----------- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index 08d1130..badbd72 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -275,12 +275,6 @@ defmodule Mv.Accounts.User do authorize_if always() end - # NoActor bypass (test fixtures only, see no_actor.ex) - bypass action_type([:create, :read, :update, :destroy]) do - description "Allow system operations without actor (test environment only)" - authorize_if Mv.Authorization.Checks.NoActor - end - # READ bypass for list queries (scope :own via expr) bypass action_type(:read) do description "Users can always read their own account" diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 650cf43..0a14efe 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -303,15 +303,6 @@ defmodule Mv.Membership.Member do # Authorization Policies # Order matters: Most specific policies first, then general permission check policies do - # SYSTEM OPERATIONS: Allow CRUD operations without actor (TEST ENVIRONMENT ONLY) - # In test: All operations allowed (for test fixtures) - # In production/dev: ALL operations denied without actor (fail-closed for security) - # NoActor.check uses compile-time environment detection to prevent security issues - bypass action_type([:create, :read, :update, :destroy]) do - description "Allow system operations without actor (test environment only)" - authorize_if Mv.Authorization.Checks.NoActor - end - # SPECIAL CASE: Users can always READ their linked member # This allows users with ANY permission set to read their own linked member # Check using the inverse relationship: User.member_id → Member.id @@ -403,8 +394,12 @@ defmodule Mv.Membership.Member do current_member_id = changeset.data.id # Get actor from changeset context for authorization - # If no actor is present, this will fail in production (fail-closed) - actor = Map.get(changeset.context || %{}, :actor) + # Use system_actor as fallback if no actor is present (for systemic operations) + actor = + case Map.get(changeset.context || %{}, :actor) do + nil -> Mv.Helpers.SystemActor.get_system_actor() + actor -> actor + end # Check the current state of the user in the database # Check if authorization is disabled in the parent operation's context -- 2.47.2 From 686f69c9e933ad120567a121571d965b184b8df3 Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 23 Jan 2026 20:03:44 +0100 Subject: [PATCH 02/15] Add authorize?: false to SystemActor bootstrap operations - Role lookup and creation (find_admin_role, create_admin_role) - System user creation and role assignment - Role loading during initialization --- lib/mv/helpers/system_actor.ex | 39 ++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/lib/mv/helpers/system_actor.ex b/lib/mv/helpers/system_actor.ex index 7a8ab8b..565c2ef 100644 --- a/lib/mv/helpers/system_actor.ex +++ b/lib/mv/helpers/system_actor.ex @@ -271,11 +271,12 @@ defmodule Mv.Helpers.SystemActor do end # Finds admin role in existing roles + # SECURITY: Uses authorize?: false for bootstrap role lookup. @spec find_admin_role() :: {:ok, Mv.Authorization.Role.t()} | {:error, :not_found} defp find_admin_role do alias Mv.Authorization - case Authorization.list_roles() do + case Authorization.list_roles(authorize?: false) do {:ok, roles} -> case Enum.find(roles, &(&1.permission_set_name == "admin")) do nil -> {:error, :not_found} @@ -305,16 +306,20 @@ defmodule Mv.Helpers.SystemActor do end # Attempts to create admin role + # SECURITY: Uses authorize?: false for bootstrap role creation. @spec create_admin_role() :: {:ok, Mv.Authorization.Role.t()} | {:error, :already_exists | term()} defp create_admin_role do alias Mv.Authorization - case Authorization.create_role(%{ - name: "Admin", - description: "Administrator with full access", - permission_set_name: "admin" - }) do + case Authorization.create_role( + %{ + name: "Admin", + description: "Administrator with full access", + permission_set_name: "admin" + }, + authorize?: false + ) do {:ok, role} -> {:ok, role} @@ -327,11 +332,12 @@ defmodule Mv.Helpers.SystemActor do end # Finds existing admin role after creation attempt failed due to race condition + # SECURITY: Uses authorize?: false for bootstrap role lookup. @spec find_existing_admin_role() :: Mv.Authorization.Role.t() | no_return() defp find_existing_admin_role do alias Mv.Authorization - case Authorization.list_roles() do + case Authorization.list_roles(authorize?: false) do {:ok, roles} -> Enum.find(roles, &(&1.permission_set_name == "admin")) || raise "Admin role should exist but was not found" @@ -350,14 +356,22 @@ defmodule Mv.Helpers.SystemActor do defp create_system_user_with_role(admin_role) do alias Mv.Accounts + # SECURITY: Uses authorize?: false for bootstrap user creation. + # This is necessary because we're creating the system actor itself, + # which would otherwise be needed for authorization (chicken-and-egg). + # This is safe because: + # 1. Only creates system user with known email + # 2. Only called during system actor initialization (bootstrap) + # 3. Once created, all subsequent operations use proper authorization Accounts.create_user!(%{email: system_user_email_config()}, 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 # Finds a user by email address @@ -376,9 +390,12 @@ defmodule Mv.Helpers.SystemActor do end # Loads a user with their role preloaded (required for authorization) + # SECURITY: Uses authorize?: false for bootstrap role loading. + # This is necessary because loading the role is part of system actor initialization, + # which would otherwise require an actor (chicken-and-egg). @spec load_user_with_role(Mv.Accounts.User.t()) :: Mv.Accounts.User.t() | no_return() defp load_user_with_role(user) do - case Ash.load(user, :role, domain: Mv.Accounts) do + case Ash.load(user, :role, domain: Mv.Accounts, authorize?: false) do {:ok, user_with_role} -> validate_admin_role(user_with_role) -- 2.47.2 From 0f48a9b15a89d0fefa41e685981f7e5588e1da92 Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 23 Jan 2026 20:00:24 +0100 Subject: [PATCH 03/15] Add actor parameter to all tests requiring authorization This commit adds actor: system_actor to all Ash operations in tests that require authorization. --- lib/membership_fees/membership_fee_type.ex | 15 +- lib/mv/membership/import/member_csv.ex | 5 +- test/accounts/email_sync_edge_cases_test.exs | 45 +- test/accounts/email_uniqueness_test.exs | 602 +++++++++++------- test/accounts/user_authentication_test.exs | 63 +- test/accounts/user_email_sync_test.exs | 67 +- test/accounts/user_member_deletion_test.exs | 59 +- .../user_member_linking_email_test.exs | 177 +++-- test/accounts/user_member_linking_test.exs | 129 ++-- .../user_member_relationship_test.exs | 180 ++++-- .../membership/custom_field_deletion_test.exs | 131 ++-- .../custom_field_show_in_overview_test.exs | 25 +- test/membership/custom_field_slug_test.exs | 115 ++-- .../custom_field_validation_test.exs | 59 +- .../custom_field_value_validation_test.exs | 105 ++- test/membership/fuzzy_search_test.exs | 532 ++++++++++------ .../member_available_for_linking_test.exs | 147 +++-- .../member_cycle_calculations_test.exs | 400 ++++++++---- test/membership/member_email_sync_test.exs | 70 +- .../member_fuzzy_search_linking_test.exs | 127 ++-- .../member_required_custom_fields_test.exs | 229 ++++--- .../member_search_with_custom_fields_test.exs | 151 +++-- test/membership/member_test.exs | 59 +- .../member_type_change_integration_test.exs | 192 +++--- .../membership_fee_settings_test.exs | 21 +- .../set_membership_fee_start_date_test.exs | 47 +- .../changes/validate_same_interval_test.exs | 101 +-- test/membership_fees/foreign_key_test.exs | 298 +++++---- .../member_cycle_integration_test.exs | 81 +-- .../membership_fee_cycle_test.exs | 120 ++-- .../membership_fee_type_integration_test.exs | 162 +++-- .../membership_fee_type_test.exs | 188 +++--- test/mv/accounts/user_policies_test.exs | 83 ++- test/mv/authorization/actor_test.exs | 19 +- .../has_permission_fail_closed_test.exs | 5 +- test/mv/authorization/role_test.exs | 9 +- test/mv/helpers/system_actor_test.exs | 88 ++- test/mv/membership/import/member_csv_test.exs | 16 +- test/mv/membership/member_policies_test.exs | 141 ++-- .../cycle_generator_edge_cases_test.exs | 291 +++++---- .../membership_fees/cycle_generator_test.exs | 268 ++++---- .../mv_web/controllers/oidc_e2e_flow_test.exs | 196 ++++-- .../controllers/oidc_email_update_test.exs | 103 +-- .../controllers/oidc_integration_test.exs | 146 +++-- .../oidc_password_linking_test.exs | 150 +++-- .../oidc_passwordless_linking_test.exs | 27 +- .../helpers/membership_fee_helpers_test.exs | 47 +- .../live/custom_field_live/deletion_test.exs | 30 +- .../membership_fee_type_live/form_test.exs | 12 +- test/mv_web/live/profile_navigation_test.exs | 16 +- test/mv_web/live/role_live/show_test.exs | 39 +- test/mv_web/live/role_live_test.exs | 68 +- test/mv_web/live/user_live/show_test.exs | 6 +- .../member_live/form_error_handling_test.exs | 10 +- .../form_membership_fee_type_test.exs | 23 +- .../index/membership_fee_status_test.exs | 39 +- ...index_custom_fields_accessibility_test.exs | 8 +- .../index_custom_fields_display_test.exs | 30 +- .../index_custom_fields_edge_cases_test.exs | 32 +- .../index_custom_fields_sorting_test.exs | 60 +- .../index_field_visibility_test.exs | 12 +- .../index_member_fields_display_test.exs | 6 +- .../index_membership_fee_status_test.exs | 28 +- test/mv_web/member_live/index_test.exs | 277 +++++--- .../membership_fee_integration_test.exs | 39 +- .../member_live/show_membership_fees_test.exs | 40 +- test/mv_web/member_live/show_test.exs | 45 +- .../user_live/form_member_dropdown_test.exs | 45 +- .../user_live/form_member_search_test.exs | 56 +- .../user_live/form_member_selection_test.exs | 142 +++-- test/mv_web/user_live/form_test.exs | 87 ++- test/mv_web/user_live/index_test.exs | 19 +- test/seeds_test.exs | 43 +- test/support/conn_case.ex | 5 +- test/support/fixtures.ex | 37 +- 75 files changed, 4686 insertions(+), 2859 deletions(-) diff --git a/lib/membership_fees/membership_fee_type.ex b/lib/membership_fees/membership_fee_type.ex index 01ae625..64ca8f9 100644 --- a/lib/membership_fees/membership_fee_type.ex +++ b/lib/membership_fees/membership_fee_type.ex @@ -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, diff --git a/lib/mv/membership/import/member_csv.ex b/lib/mv/membership/import/member_csv.ex index d56c56e..f2e7591 100644 --- a/lib/mv/membership/import/member_csv.ex +++ b/lib/mv/membership/import/member_csv.ex @@ -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} diff --git a/test/accounts/email_sync_edge_cases_test.exs b/test/accounts/email_sync_edge_cases_test.exs index b872235..00ae5f9 100644 --- a/test/accounts/email_sync_edge_cases_test.exs +++ b/test/accounts/email_sync_edge_cases_test.exs @@ -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 diff --git a/test/accounts/email_uniqueness_test.exs b/test/accounts/email_uniqueness_test.exs index a16ebdd..4a21b39 100644 --- a/test/accounts/email_uniqueness_test.exs +++ b/test/accounts/email_uniqueness_test.exs @@ -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 diff --git a/test/accounts/user_authentication_test.exs b/test/accounts/user_authentication_test.exs index caa3359..da84e81 100644 --- a/test/accounts/user_authentication_test.exs +++ b/test/accounts/user_authentication_test.exs @@ -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 diff --git a/test/accounts/user_email_sync_test.exs b/test/accounts/user_email_sync_test.exs index 6d08d61..d324783 100644 --- a/test/accounts/user_email_sync_test.exs +++ b/test/accounts/user_email_sync_test.exs @@ -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" diff --git a/test/accounts/user_member_deletion_test.exs b/test/accounts/user_member_deletion_test.exs index 52a3865..f4aac89 100644 --- a/test/accounts/user_member_deletion_test.exs +++ b/test/accounts/user_member_deletion_test.exs @@ -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 diff --git a/test/accounts/user_member_linking_email_test.exs b/test/accounts/user_member_linking_email_test.exs index d7c2817..62886ca 100644 --- a/test/accounts/user_member_linking_email_test.exs +++ b/test/accounts/user_member_linking_email_test.exs @@ -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 diff --git a/test/accounts/user_member_linking_test.exs b/test/accounts/user_member_linking_test.exs index 1111436..54c7aa5 100644 --- a/test/accounts/user_member_linking_test.exs +++ b/test/accounts/user_member_linking_test.exs @@ -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 diff --git a/test/accounts/user_member_relationship_test.exs b/test/accounts/user_member_relationship_test.exs index b64f5ec..881f393 100644 --- a/test/accounts/user_member_relationship_test.exs +++ b/test/accounts/user_member_relationship_test.exs @@ -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 diff --git a/test/membership/custom_field_deletion_test.exs b/test/membership/custom_field_deletion_test.exs index 50623b6..ffc7294 100644 --- a/test/membership/custom_field_deletion_test.exs +++ b/test/membership/custom_field_deletion_test.exs @@ -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 diff --git a/test/membership/custom_field_show_in_overview_test.exs b/test/membership/custom_field_show_in_overview_test.exs index adac600..a9e0345 100644 --- a/test/membership/custom_field_show_in_overview_test.exs +++ b/test/membership/custom_field_show_in_overview_test.exs @@ -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 diff --git a/test/membership/custom_field_slug_test.exs b/test/membership/custom_field_slug_test.exs index ae6c42e..76ab5c7 100644 --- a/test/membership/custom_field_slug_test.exs +++ b/test/membership/custom_field_slug_test.exs @@ -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 diff --git a/test/membership/custom_field_validation_test.exs b/test/membership/custom_field_validation_test.exs index a5c1f2d..d0711ad 100644 --- a/test/membership/custom_field_validation_test.exs +++ b/test/membership/custom_field_validation_test.exs @@ -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 diff --git a/test/membership/custom_field_value_validation_test.exs b/test/membership/custom_field_value_validation_test.exs index dd3438a..d39e85c 100644 --- a/test/membership/custom_field_value_validation_test.exs +++ b/test/membership/custom_field_value_validation_test.exs @@ -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 -> diff --git a/test/membership/fuzzy_search_test.exs b/test/membership/fuzzy_search_test.exs index 19286df..257d097 100644 --- a/test/membership/fuzzy_search_test.exs +++ b/test/membership/fuzzy_search_test.exs @@ -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) diff --git a/test/membership/member_available_for_linking_test.exs b/test/membership/member_available_for_linking_test.exs index 2f3e018..5cf9c5b 100644 --- a/test/membership/member_available_for_linking_test.exs +++ b/test/membership/member_available_for_linking_test.exs @@ -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) diff --git a/test/membership/member_cycle_calculations_test.exs b/test/membership/member_cycle_calculations_test.exs index 5a9e501..030aa8b 100644 --- a/test/membership/member_cycle_calculations_test.exs +++ b/test/membership/member_cycle_calculations_test.exs @@ -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]) diff --git a/test/membership/member_email_sync_test.exs b/test/membership/member_email_sync_test.exs index eeef210..784ebcc 100644 --- a/test/membership/member_email_sync_test.exs +++ b/test/membership/member_email_sync_test.exs @@ -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 diff --git a/test/membership/member_fuzzy_search_linking_test.exs b/test/membership/member_fuzzy_search_linking_test.exs index 4cbd8d9..f730eec 100644 --- a/test/membership/member_fuzzy_search_linking_test.exs +++ b/test/membership/member_fuzzy_search_linking_test.exs @@ -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) diff --git a/test/membership/member_required_custom_fields_test.exs b/test/membership/member_required_custom_fields_test.exs index ec8ebe3..c3ede0f 100644 --- a/test/membership/member_required_custom_fields_test.exs +++ b/test/membership/member_required_custom_fields_test.exs @@ -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 diff --git a/test/membership/member_search_with_custom_fields_test.exs b/test/membership/member_search_with_custom_fields_test.exs index 6711df8..bd28ce5 100644 --- a/test/membership/member_search_with_custom_fields_test.exs +++ b/test/membership/member_search_with_custom_fields_test.exs @@ -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 diff --git a/test/membership/member_test.exs b/test/membership/member_test.exs index 6919ec1..705ab61 100644 --- a/test/membership/member_test.exs +++ b/test/membership/member_test.exs @@ -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 diff --git a/test/membership/member_type_change_integration_test.exs b/test/membership/member_type_change_integration_test.exs index f2dd0e0..24d4355 100644 --- a/test/membership/member_type_change_integration_test.exs +++ b/test/membership/member_type_change_integration_test.exs @@ -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 diff --git a/test/membership/membership_fee_settings_test.exs b/test/membership/membership_fee_settings_test.exs index 05a0d04..744b6bd 100644 --- a/test/membership/membership_fee_settings_test.exs +++ b/test/membership/membership_fee_settings_test.exs @@ -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 diff --git a/test/membership_fees/changes/set_membership_fee_start_date_test.exs b/test/membership_fees/changes/set_membership_fee_start_date_test.exs index 4af59db..0f8bae9 100644 --- a/test/membership_fees/changes/set_membership_fee_start_date_test.exs +++ b/test/membership_fees/changes/set_membership_fee_start_date_test.exs @@ -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) diff --git a/test/membership_fees/changes/validate_same_interval_test.exs b/test/membership_fees/changes/validate_same_interval_test.exs index 0f4501c..82fbd6b 100644 --- a/test/membership_fees/changes/validate_same_interval_test.exs +++ b/test/membership_fees/changes/validate_same_interval_test.exs @@ -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 diff --git a/test/membership_fees/foreign_key_test.exs b/test/membership_fees/foreign_key_test.exs index dd164a7..54a7cc5 100644 --- a/test/membership_fees/foreign_key_test.exs +++ b/test/membership_fees/foreign_key_test.exs @@ -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 diff --git a/test/membership_fees/member_cycle_integration_test.exs b/test/membership_fees/member_cycle_integration_test.exs index 5d1cf28..6d5bc2e 100644 --- a/test/membership_fees/member_cycle_integration_test.exs +++ b/test/membership_fees/member_cycle_integration_test.exs @@ -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) diff --git a/test/membership_fees/membership_fee_cycle_test.exs b/test/membership_fees/membership_fee_cycle_test.exs index 14bdf4b..4f78d1b 100644 --- a/test/membership_fees/membership_fee_cycle_test.exs +++ b/test/membership_fees/membership_fee_cycle_test.exs @@ -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 diff --git a/test/membership_fees/membership_fee_type_integration_test.exs b/test/membership_fees/membership_fee_type_integration_test.exs index 681bd02..e716b42 100644 --- a/test/membership_fees/membership_fee_type_integration_test.exs +++ b/test/membership_fees/membership_fee_type_integration_test.exs @@ -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) diff --git a/test/membership_fees/membership_fee_type_test.exs b/test/membership_fees/membership_fee_type_test.exs index 626e096..80b7839 100644 --- a/test/membership_fees/membership_fee_type_test.exs +++ b/test/membership_fees/membership_fee_type_test.exs @@ -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 diff --git a/test/mv/accounts/user_policies_test.exs b/test/mv/accounts/user_policies_test.exs index bacb19d..3bbf54b 100644 --- a/test/mv/accounts/user_policies_test.exs +++ b/test/mv/accounts/user_policies_test.exs @@ -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 diff --git a/test/mv/authorization/actor_test.exs b/test/mv/authorization/actor_test.exs index e542301..5d8266b 100644 --- a/test/mv/authorization/actor_test.exs +++ b/test/mv/authorization/actor_test.exs @@ -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) diff --git a/test/mv/authorization/checks/has_permission_fail_closed_test.exs b/test/mv/authorization/checks/has_permission_fail_closed_test.exs index 822e5aa..3e337c9 100644 --- a/test/mv/authorization/checks/has_permission_fail_closed_test.exs +++ b/test/mv/authorization/checks/has_permission_fail_closed_test.exs @@ -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 == [] diff --git a/test/mv/authorization/role_test.exs b/test/mv/authorization/role_test.exs index b263455..b7aa632 100644 --- a/test/mv/authorization/role_test.exs +++ b/test/mv/authorization/role_test.exs @@ -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) diff --git a/test/mv/helpers/system_actor_test.exs b/test/mv/helpers/system_actor_test.exs index 751f5c5..77596f6 100644 --- a/test/mv/helpers/system_actor_test.exs +++ b/test/mv/helpers/system_actor_test.exs @@ -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() diff --git a/test/mv/membership/import/member_csv_test.exs b/test/mv/membership/import/member_csv_test.exs index 98943d5..5cb40d6 100644 --- a/test/mv/membership/import/member_csv_test.exs +++ b/test/mv/membership/import/member_csv_test.exs @@ -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 diff --git a/test/mv/membership/member_policies_test.exs b/test/mv/membership/member_policies_test.exs index 69b0e22..cee8d97 100644 --- a/test/mv/membership/member_policies_test.exs +++ b/test/mv/membership/member_policies_test.exs @@ -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} = diff --git a/test/mv/membership_fees/cycle_generator_edge_cases_test.exs b/test/mv/membership_fees/cycle_generator_edge_cases_test.exs index 85eb406..d4899a3 100644 --- a/test/mv/membership_fees/cycle_generator_edge_cases_test.exs +++ b/test/mv/membership_fees/cycle_generator_edge_cases_test.exs @@ -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 diff --git a/test/mv/membership_fees/cycle_generator_test.exs b/test/mv/membership_fees/cycle_generator_test.exs index e6988da..1863312 100644 --- a/test/mv/membership_fees/cycle_generator_test.exs +++ b/test/mv/membership_fees/cycle_generator_test.exs @@ -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] diff --git a/test/mv_web/controllers/oidc_e2e_flow_test.exs b/test/mv_web/controllers/oidc_e2e_flow_test.exs index 3b4a22f..fbd59d2 100644 --- a/test/mv_web/controllers/oidc_e2e_flow_test.exs +++ b/test/mv_web/controllers/oidc_e2e_flow_test.exs @@ -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) diff --git a/test/mv_web/controllers/oidc_email_update_test.exs b/test/mv_web/controllers/oidc_email_update_test.exs index 53a6514..b486b71 100644 --- a/test/mv_web/controllers/oidc_email_update_test.exs +++ b/test/mv_web/controllers/oidc_email_update_test.exs @@ -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 diff --git a/test/mv_web/controllers/oidc_integration_test.exs b/test/mv_web/controllers/oidc_integration_test.exs index bc12196..650158a 100644 --- a/test/mv_web/controllers/oidc_integration_test.exs +++ b/test/mv_web/controllers/oidc_integration_test.exs @@ -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" diff --git a/test/mv_web/controllers/oidc_password_linking_test.exs b/test/mv_web/controllers/oidc_password_linking_test.exs index a898f95..e9e3876 100644 --- a/test/mv_web/controllers/oidc_password_linking_test.exs +++ b/test/mv_web/controllers/oidc_password_linking_test.exs @@ -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 diff --git a/test/mv_web/controllers/oidc_passwordless_linking_test.exs b/test/mv_web/controllers/oidc_passwordless_linking_test.exs index 9da66ac..1b5753f 100644 --- a/test/mv_web/controllers/oidc_passwordless_linking_test.exs +++ b/test/mv_web/controllers/oidc_passwordless_linking_test.exs @@ -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" diff --git a/test/mv_web/helpers/membership_fee_helpers_test.exs b/test/mv_web/helpers/membership_fee_helpers_test.exs index 6d6d35c..d5b0571 100644 --- a/test/mv_web/helpers/membership_fee_helpers_test.exs +++ b/test/mv_web/helpers/membership_fee_helpers_test.exs @@ -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 = diff --git a/test/mv_web/live/custom_field_live/deletion_test.exs b/test/mv_web/live/custom_field_live/deletion_test.exs index a35c06c..9610b24 100644 --- a/test/mv_web/live/custom_field_live/deletion_test.exs +++ b/test/mv_web/live/custom_field_live/deletion_test.exs @@ -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 diff --git a/test/mv_web/live/membership_fee_type_live/form_test.exs b/test/mv_web/live/membership_fee_type_live/form_test.exs index 8576f6f..9398403 100644 --- a/test/mv_web/live/membership_fee_type_live/form_test.exs +++ b/test/mv_web/live/membership_fee_type_live/form_test.exs @@ -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 diff --git a/test/mv_web/live/profile_navigation_test.exs b/test/mv_web/live/profile_navigation_test.exs index cac6802..b104900 100644 --- a/test/mv_web/live/profile_navigation_test.exs +++ b/test/mv_web/live/profile_navigation_test.exs @@ -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) diff --git a/test/mv_web/live/role_live/show_test.exs b/test/mv_web/live/role_live/show_test.exs index 2c56347..48edef6 100644 --- a/test/mv_web/live/role_live/show_test.exs +++ b/test/mv_web/live/role_live/show_test.exs @@ -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 diff --git a/test/mv_web/live/role_live_test.exs b/test/mv_web/live/role_live_test.exs index 792cbac..257dd3e 100644 --- a/test/mv_web/live/role_live_test.exs +++ b/test/mv_web/live/role_live_test.exs @@ -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 diff --git a/test/mv_web/live/user_live/show_test.exs b/test/mv_web/live/user_live/show_test.exs index 054640c..3551fdf 100644 --- a/test/mv_web/live/user_live/show_test.exs +++ b/test/mv_web/live/user_live/show_test.exs @@ -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}") diff --git a/test/mv_web/member_live/form_error_handling_test.exs b/test/mv_web/member_live/form_error_handling_test.exs index 859402e..07a3cfe 100644 --- a/test/mv_web/member_live/form_error_handling_test.exs +++ b/test/mv_web/member_live/form_error_handling_test.exs @@ -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") diff --git a/test/mv_web/member_live/form_membership_fee_type_test.exs b/test/mv_web/member_live/form_membership_fee_type_test.exs index 4293e67..93f4b6a 100644 --- a/test/mv_web/member_live/form_membership_fee_type_test.exs +++ b/test/mv_web/member_live/form_membership_fee_type_test.exs @@ -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}) diff --git a/test/mv_web/member_live/index/membership_fee_status_test.exs b/test/mv_web/member_live/index/membership_fee_status_test.exs index c56e80c..331375e 100644 --- a/test/mv_web/member_live/index/membership_fee_status_test.exs +++ b/test/mv_web/member_live/index/membership_fee_status_test.exs @@ -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 = diff --git a/test/mv_web/member_live/index_custom_fields_accessibility_test.exs b/test/mv_web/member_live/index_custom_fields_accessibility_test.exs index 149d441..571555e 100644 --- a/test/mv_web/member_live/index_custom_fields_accessibility_test.exs +++ b/test/mv_web/member_live/index_custom_fields_accessibility_test.exs @@ -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 diff --git a/test/mv_web/member_live/index_custom_fields_display_test.exs b/test/mv_web/member_live/index_custom_fields_display_test.exs index b720099..287a915 100644 --- a/test/mv_web/member_live/index_custom_fields_display_test.exs +++ b/test/mv_web/member_live/index_custom_fields_display_test.exs @@ -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, diff --git a/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs b/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs index d526556..cdf26f1 100644 --- a/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs +++ b/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs @@ -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") diff --git a/test/mv_web/member_live/index_custom_fields_sorting_test.exs b/test/mv_web/member_live/index_custom_fields_sorting_test.exs index 21b0c9f..88f225f 100644 --- a/test/mv_web/member_live/index_custom_fields_sorting_test.exs +++ b/test/mv_web/member_live/index_custom_fields_sorting_test.exs @@ -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) diff --git a/test/mv_web/member_live/index_field_visibility_test.exs b/test/mv_web/member_live/index_field_visibility_test.exs index 05fa768..d471a23 100644 --- a/test/mv_web/member_live/index_field_visibility_test.exs +++ b/test/mv_web/member_live/index_field_visibility_test.exs @@ -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, diff --git a/test/mv_web/member_live/index_member_fields_display_test.exs b/test/mv_web/member_live/index_member_fields_display_test.exs index c6fd39f..ca6ffb0 100644 --- a/test/mv_web/member_live/index_member_fields_display_test.exs +++ b/test/mv_web/member_live/index_member_fields_display_test.exs @@ -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, diff --git a/test/mv_web/member_live/index_membership_fee_status_test.exs b/test/mv_web/member_live/index_membership_fee_status_test.exs index a189873..043c5cb 100644 --- a/test/mv_web/member_live/index_membership_fee_status_test.exs +++ b/test/mv_web/member_live/index_membership_fee_status_test.exs @@ -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) diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 3391b86..bf412d8 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -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") diff --git a/test/mv_web/member_live/membership_fee_integration_test.exs b/test/mv_web/member_live/membership_fee_integration_test.exs index 9358c70..2636419 100644 --- a/test/mv_web/member_live/membership_fee_integration_test.exs +++ b/test/mv_web/member_live/membership_fee_integration_test.exs @@ -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 diff --git a/test/mv_web/member_live/show_membership_fees_test.exs b/test/mv_web/member_live/show_membership_fees_test.exs index d0402c3..e41f02f 100644 --- a/test/mv_web/member_live/show_membership_fees_test.exs +++ b/test/mv_web/member_live/show_membership_fees_test.exs @@ -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 diff --git a/test/mv_web/member_live/show_test.exs b/test/mv_web/member_live/show_test.exs index fdcfebb..d2c6e55 100644 --- a/test/mv_web/member_live/show_test.exs +++ b/test/mv_web/member_live/show_test.exs @@ -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}") diff --git a/test/mv_web/user_live/form_member_dropdown_test.exs b/test/mv_web/user_live/form_member_dropdown_test.exs index 0e93d4d..c4387ce 100644 --- a/test/mv_web/user_live/form_member_dropdown_test.exs +++ b/test/mv_web/user_live/form_member_dropdown_test.exs @@ -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 diff --git a/test/mv_web/user_live/form_member_search_test.exs b/test/mv_web/user_live/form_member_search_test.exs index b2644f3..e45df49 100644 --- a/test/mv_web/user_live/form_member_search_test.exs +++ b/test/mv_web/user_live/form_member_search_test.exs @@ -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") diff --git a/test/mv_web/user_live/form_member_selection_test.exs b/test/mv_web/user_live/form_member_selection_test.exs index 74810df..2ee3caa 100644 --- a/test/mv_web/user_live/form_member_selection_test.exs +++ b/test/mv_web/user_live/form_member_selection_test.exs @@ -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") diff --git a/test/mv_web/user_live/form_test.exs b/test/mv_web/user_live/form_test.exs index 334dedd..ed309fb 100644 --- a/test/mv_web/user_live/form_test.exs +++ b/test/mv_web/user_live/form_test.exs @@ -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 diff --git a/test/mv_web/user_live/index_test.exs b/test/mv_web/user_live/index_test.exs index 360ef72..41c198d 100644 --- a/test/mv_web/user_live/index_test.exs +++ b/test/mv_web/user_live/index_test.exs @@ -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"}) diff --git a/test/seeds_test.exs b/test/seeds_test.exs index c28eab9..3472616 100644 --- a/test/seeds_test.exs +++ b/test/seeds_test.exs @@ -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) diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 3b2a5ed..290b3ac 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -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 diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex index d474764..af67ffa 100644 --- a/test/support/fixtures.ex +++ b/test/support/fixtures.ex @@ -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) -- 2.47.2 From d9eb131d96425a8028f996a53730027c5926c58b Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 23 Jan 2026 20:18:28 +0100 Subject: [PATCH 04/15] Update documentation: Remove NoActor bypass references --- CODE_GUIDELINES.md | 92 ++++++++----------- docs/policy-bypass-vs-haspermission.md | 2 +- docs/roles-and-permissions-architecture.md | 88 +++++------------- ...les-and-permissions-implementation-plan.md | 15 +-- ...esource-policies-implementation-summary.md | 13 +-- 5 files changed, 73 insertions(+), 137 deletions(-) diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 5bee497..987be42 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -690,16 +690,9 @@ end **Authorization Bootstrap Patterns:** -Three mechanisms exist for bypassing standard authorization: +Two mechanisms exist for bypassing standard authorization: -1. **NoActor** (test only) - Allows operations without actor in test environment - ```elixir - # Automatically enabled in tests via config/test.exs - # Policies use: bypass action_type(...) do authorize_if NoActor end - member = create_member(%{name: "Test"}) # Works in tests - ``` - -2. **system_actor** (systemic operations) - Admin user for operations that must always succeed +1. **system_actor** (systemic operations) - Admin user for operations that must always succeed ```elixir # Good: Systemic operation system_actor = SystemActor.get_system_actor() @@ -709,7 +702,7 @@ Three mechanisms exist for bypassing standard authorization: # Never use system_actor for user-initiated actions! ``` -3. **authorize?: false** (bootstrap only) - Skips policies for circular dependencies +2. **authorize?: false** (bootstrap only) - Skips policies for circular dependencies ```elixir # Good: Bootstrap (seeds, SystemActor loading) Accounts.create_user!(%{email: admin_email}, authorize?: false) @@ -719,10 +712,10 @@ Three mechanisms exist for bypassing standard authorization: ``` **Decision Guide:** -- Use **NoActor** for test fixtures (automatic via config) -- Use **system_actor** for email sync, cycle generation, validations +- Use **system_actor** for email sync, cycle generation, validations, and test fixtures - Use **authorize?: false** only for bootstrap (seeds, circular dependencies) - Always document why `authorize?: false` is necessary +- **Note:** NoActor bypass was removed to prevent masking authorization bugs in tests **See also:** `docs/roles-and-permissions-architecture.md` (Authorization Bootstrap Patterns section) @@ -1702,65 +1695,54 @@ case Ash.read(Mv.Membership.Member, actor: actor) do end ``` -### 5.1a NoActor Pattern - Test Environment Only +### 5.1a Authorization in Tests -**IMPORTANT:** The `Mv.Authorization.Checks.NoActor` check is **ONLY for test environment**. It must NEVER be used in production. +**IMPORTANT:** All tests must explicitly provide an actor for Ash operations. The NoActor bypass has been removed to prevent masking authorization bugs. -**What NoActor Does:** +**Test Fixtures:** -- Allows CRUD operations without an actor in **test environment only** -- Denies all operations without an actor in **production/dev** (fail-closed) -- Uses compile-time config check to prevent accidental production use (release-safe) - -**Security Guards:** +All test fixtures use `system_actor` for authorization: ```elixir -# config/test.exs -config :mv, :allow_no_actor_bypass, true - -# lib/mv/authorization/checks/no_actor.ex -# Compile-time check from config (release-safe, no Mix.env) -@allow_no_actor_bypass Application.compile_env(:mv, :allow_no_actor_bypass, false) - -# Uses compile-time flag only (no runtime Mix.env needed) -def match?(nil, _context, _opts) do - @allow_no_actor_bypass # true in test, false in prod/dev +# test/support/fixtures.ex +def member_fixture(attrs \\ %{}) do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + attrs + |> Enum.into(%{...}) + |> Mv.Membership.create_member(actor: system_actor) end ``` -**Why This Pattern Exists:** +**Why Explicit Actors in Tests:** -- Test fixtures often need to create resources without an actor -- Production operations MUST always have an actor for security -- Config-based guard (not Mix.env) ensures release-safety -- Defaults to `false` (fail-closed) if config not set +- Prevents masking authorization bugs +- Makes authorization requirements explicit +- Tests fail if authorization is broken (fail-fast) +- Consistent with production code patterns -**NEVER Use NoActor in Production:** +**Using system_actor in Tests:** ```elixir -# ❌ BAD - Don't do this in production code -Ash.create!(Member, attrs) # No actor - will fail in prod - -# ✅ GOOD - Use admin actor for system operations -admin_user = get_admin_user() -Ash.create!(Member, attrs, actor: admin_user) -``` - -**Alternative: System Actor Pattern** - -For production system operations, use the System Actor Pattern (see Section 3.3) instead of NoActor: - -```elixir -# System operations in production -system_actor = get_system_actor() +# ✅ GOOD - Explicit actor in tests +system_actor = Mv.Helpers.SystemActor.get_system_actor() Ash.create!(Member, attrs, actor: system_actor) + +# ❌ BAD - Missing actor (will fail) +Ash.create!(Member, attrs) # Forbidden error! ``` -**Testing:** +**For Bootstrap Operations:** -- NoActor tests verify the compile-time config guard -- Production safety is guaranteed by config (only set in test.exs, defaults to false) -- See `test/mv/authorization/checks/no_actor_test.exs` +Use `authorize?: false` only for bootstrap scenarios (seeds, SystemActor initialization): + +```elixir +# ✅ GOOD - Bootstrap only +Accounts.create_user!(%{email: admin_email}, authorize?: false) + +# ❌ BAD - Never use in tests for normal operations +Ash.create!(Member, attrs, authorize?: false) # Never do this! +``` ### 5.2 Password Security diff --git a/docs/policy-bypass-vs-haspermission.md b/docs/policy-bypass-vs-haspermission.md index 8a65c6f..31bb737 100644 --- a/docs/policy-bypass-vs-haspermission.md +++ b/docs/policy-bypass-vs-haspermission.md @@ -262,7 +262,7 @@ The bypass is not a design choice but a **technical necessity** due to Ash's pol - ✅ UPDATE operations via HasPermission with `scope :own` - ✅ Admin operations via HasPermission with `scope :all` - ✅ AshAuthentication bypass (registration/login) -- ✅ NoActor bypass (test environment) +- ✅ Tests use system_actor for authorization **Key Tests Proving Pattern:** diff --git a/docs/roles-and-permissions-architecture.md b/docs/roles-and-permissions-architecture.md index bc1b75c..8934688 100644 --- a/docs/roles-and-permissions-architecture.md +++ b/docs/roles-and-permissions-architecture.md @@ -946,12 +946,7 @@ defmodule Mv.Accounts.User do authorize_if always() end - # 2. NoActor Bypass (test environment only, for test fixtures) - bypass action_type([:create, :read, :update, :destroy]) do - authorize_if Mv.Authorization.Checks.NoActor - end - - # 3. SPECIAL CASE: Users can always READ their own account + # 2. SPECIAL CASE: Users can always READ their own account # Bypass needed for list queries (expr() triggers auto_filter in Ash) # UPDATE is handled by HasPermission below (scope :own works with changesets) bypass action_type(:read) do @@ -959,7 +954,7 @@ defmodule Mv.Accounts.User do authorize_if expr(id == ^actor(:id)) end - # 4. GENERAL: Check permissions from user's role + # 3. GENERAL: Check permissions from user's role # - :own_data → can UPDATE own user (scope :own via HasPermission) # - :read_only → can UPDATE own user (scope :own via HasPermission) # - :normal_user → can UPDATE own user (scope :own via HasPermission) @@ -969,7 +964,7 @@ defmodule Mv.Accounts.User do authorize_if Mv.Authorization.Checks.HasPermission end - # 5. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed) + # 4. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed) end # ... @@ -1007,12 +1002,7 @@ defmodule Mv.Membership.Member do use Ash.Resource, ... policies do - # 1. NoActor Bypass (test environment only, for test fixtures) - bypass action_type([:create, :read, :update, :destroy]) do - authorize_if Mv.Authorization.Checks.NoActor - end - - # 2. SPECIAL CASE: Users can always READ their linked member + # 1. SPECIAL CASE: Users can always READ their linked member # Bypass needed for list queries (expr() triggers auto_filter in Ash) # UPDATE is handled by HasPermission below (scope :linked works with changesets) bypass action_type(:read) do @@ -1020,7 +1010,7 @@ defmodule Mv.Membership.Member do authorize_if expr(id == ^actor(:member_id)) end - # 3. GENERAL: Check permissions from role + # 2. GENERAL: Check permissions from role # - :own_data → can UPDATE linked member (scope :linked via HasPermission) # - :read_only → can READ all members (scope :all), no update permission # - :normal_user → can CRUD all members (scope :all) @@ -2629,45 +2619,16 @@ This section clarifies three different mechanisms for bypassing standard authori ### Overview -The codebase uses three authorization bypass mechanisms: +The codebase uses two authorization bypass mechanisms: -1. **NoActor** - Test-only bypass (compile-time secured) -2. **system_actor** - Admin user for systemic operations -3. **authorize?: false** - Bootstrap bypass for circular dependencies +1. **system_actor** - Admin user for systemic operations +2. **authorize?: false** - Bootstrap bypass for circular dependencies -**All three are necessary and serve different purposes.** +**Both are necessary and serve different purposes.** -### 1. NoActor Check +**Note:** The NoActor bypass has been removed to prevent masking authorization bugs in tests. All tests now explicitly use `system_actor` for authorization. -**Purpose:** Allows CRUD operations without actor in test environment only. - -**Implementation:** -```elixir -# lib/mv/authorization/checks/no_actor.ex -@allow_no_actor_bypass Application.compile_env(:mv, :allow_no_actor_bypass, false) - -def match?(nil, _context, _opts) do - @allow_no_actor_bypass # true in test.exs, false elsewhere -end -``` - -**Security:** -- Compile-time flag (not runtime `Mix.env()` check) -- Default: false (fail-closed) -- Only enabled in `config/test.exs` - -**Use Case:** Test fixtures without verbose actor setup: -```elixir -# With NoActor (test environment only) -member = create_member(%{name: "Test"}) - -# Production behavior (NoActor returns false) -member = create_member(%{name: "Test"}, actor: user) -``` - -**Trade-off:** May mask tests that should fail without actor. Mitigated by explicit policy tests (e.g., `test/mv/accounts/user_policies_test.exs`). - -### 2. System Actor +### 1. System Actor **Purpose:** Admin user for systemic operations that must always succeed regardless of user permissions. @@ -2708,7 +2669,7 @@ end - Consistent authorization flow - Testable -### 3. authorize?: false +### 2. authorize?: false **Purpose:** Skip policies for bootstrap scenarios with circular dependencies. @@ -2759,21 +2720,17 @@ Mv.Authorization.Role ### Comparison -| Aspect | NoActor | system_actor | authorize?: false | -|--------|---------|--------------|-------------------| -| **Environment** | Test only | All | All | -| **Actor** | nil | Admin user | nil | -| **Policies** | Bypassed | Evaluated | Skipped | -| **Audit Trail** | No | Yes (system@mila.local) | No | -| **Use Case** | Test fixtures | Systemic operations | Bootstrap | -| **Explicit?** | Policy bypass | Function call | Query option | +| Aspect | system_actor | authorize?: false | +|--------|--------------|-------------------| +| **Environment** | All | All | +| **Actor** | Admin user | nil | +| **Policies** | Evaluated | Skipped | +| **Audit Trail** | Yes (system@mila.local) | No | +| **Use Case** | Systemic operations, test fixtures | Bootstrap | +| **Explicit?** | Function call | Query option | ### Decision Guide -**Use NoActor when:** -- ✅ Writing test fixtures -- ✅ Compile-time guard ensures test-only - **Use system_actor when:** - ✅ Systemic operation must always succeed - ✅ Email synchronization @@ -2789,7 +2746,7 @@ Mv.Authorization.Role **DON'T:** - ❌ Use `authorize?: false` for user-initiated actions - ❌ Use `authorize?: false` when `system_actor` would work -- ❌ Enable NoActor outside test environment +- ❌ Skip actor in tests (always use system_actor) ### The Circular Dependency Problem @@ -2873,7 +2830,8 @@ end - Enhanced edge case documentation **Changes from V2.0:** -- Added "Authorization Bootstrap Patterns" section explaining NoActor, system_actor, and authorize?: false +- Added "Authorization Bootstrap Patterns" section explaining system_actor and authorize?: false +- Removed NoActor bypass (all tests now use system_actor for explicit authorization) --- diff --git a/docs/roles-and-permissions-implementation-plan.md b/docs/roles-and-permissions-implementation-plan.md index 33b1702..23b045c 100644 --- a/docs/roles-and-permissions-implementation-plan.md +++ b/docs/roles-and-permissions-implementation-plan.md @@ -542,7 +542,7 @@ Following the same pattern as Member resource: 1. ✅ Open `lib/accounts/user.ex` 2. ✅ Add `policies` block 3. ✅ Add AshAuthentication bypass (registration/login without actor) -4. ✅ Add NoActor bypass (test environment only) +4. ✅ ~~Add NoActor bypass (test environment only)~~ **REMOVED** - NoActor bypass was removed to prevent masking authorization bugs. All tests now use `system_actor`. 5. ✅ Add bypass for READ: Allow user to always read their own account ```elixir bypass action_type(:read) do @@ -556,10 +556,11 @@ Following the same pattern as Member resource: **Policy Order:** 1. ✅ AshAuthentication bypass (registration/login) -2. ✅ NoActor bypass (test environment) -3. ✅ Bypass: User can READ own account (id == actor.id) -4. ✅ HasPermission: General permission check (UPDATE uses scope :own, admin uses scope :all) -5. ✅ Default: Ash implicitly forbids (fail-closed) +2. ✅ Bypass: User can READ own account (id == actor.id) +3. ✅ HasPermission: General permission check (UPDATE uses scope :own, admin uses scope :all) +4. ✅ Default: Ash implicitly forbids (fail-closed) + +**Note:** NoActor bypass was removed. All tests now use `system_actor` for authorization. **Why Bypass for READ but not UPDATE?** @@ -574,7 +575,7 @@ This ensures `scope :own` in PermissionSets is actually used (not redundant). - ✅ User can always update own credentials (via HasPermission with scope :own) - ✅ Only admin can read/update other users (scope :all) - ✅ Only admin can destroy users (scope :all) -- ✅ Policy order is correct (AshAuth → NoActor → Bypass READ → HasPermission) +- ✅ Policy order is correct (AshAuth → Bypass READ → HasPermission) - ✅ Actor preloads :role relationship - ✅ All tests pass (30/31 pass, 1 skipped) @@ -584,7 +585,7 @@ This ensures `scope :own` in PermissionSets is actually used (not redundant). - ✅ 31 tests total: 30 passing, 1 skipped (AshAuthentication edge case) - ✅ Tests for all 4 permission sets: own_data, read_only, normal_user, admin - ✅ Tests for AshAuthentication bypass (registration/login) -- ✅ Tests for NoActor bypass (test environment) +- ✅ Tests use system_actor for authorization (NoActor bypass removed) - ✅ Tests verify scope :own is used for UPDATE (not redundant) --- diff --git a/docs/user-resource-policies-implementation-summary.md b/docs/user-resource-policies-implementation-summary.md index c85d3d7..c939c6b 100644 --- a/docs/user-resource-policies-implementation-summary.md +++ b/docs/user-resource-policies-implementation-summary.md @@ -22,18 +22,13 @@ policies do authorize_if always() end - # 2. NoActor Bypass (test environment only) - bypass action_type([:create, :read, :update, :destroy]) do - authorize_if Mv.Authorization.Checks.NoActor - end - - # 3. Bypass for READ (list queries via auto_filter) + # 2. Bypass for READ (list queries via auto_filter) bypass action_type(:read) do description "Users can always read their own account" authorize_if expr(id == ^actor(:id)) end - # 4. HasPermission for all operations (uses scope from PermissionSets) + # 3. HasPermission for all operations (uses scope from PermissionSets) policy action_type([:read, :create, :update, :destroy]) do description "Check permissions from user's role and permission set" authorize_if Mv.Authorization.Checks.HasPermission @@ -51,7 +46,7 @@ end - ✅ CREATE operations (admin only) - ✅ DESTROY operations (admin only) - ✅ AshAuthentication bypass (registration/login) -- ✅ NoActor bypass (test environment) +- ✅ Tests use system_actor for authorization --- @@ -190,7 +185,7 @@ mix test test/mv/accounts/user_policies_test.exs \ **Test Environment:** - ✅ Operations without actor work in test environment -- ✅ NoActor bypass correctly detects compile-time environment +- ✅ All tests explicitly use system_actor for authorization --- -- 2.47.2 From 9e20766ef20606d728e86752e9edfdafc79bb0a7 Mon Sep 17 00:00:00 2001 From: Moritz Date: Sat, 24 Jan 2026 01:42:15 +0100 Subject: [PATCH 05/15] Use authorize?: false for integrity checks in validations --- lib/membership/member.ex | 27 +++++++--------------- lib/membership_fees/membership_fee_type.ex | 18 +++++---------- 2 files changed, 14 insertions(+), 31 deletions(-) diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 0a14efe..f2f27c0 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -393,25 +393,11 @@ defmodule Mv.Membership.Member do user_id = user_arg[:id] current_member_id = changeset.data.id - # Get actor from changeset context for authorization - # Use system_actor as fallback if no actor is present (for systemic operations) - actor = - case Map.get(changeset.context || %{}, :actor) do - nil -> Mv.Helpers.SystemActor.get_system_actor() - actor -> actor - end - - # Check the current state of the user in the database - # Check if authorization is disabled in the parent operation's context - # Access private context where authorize? flag is stored - authorize? = - case get_in(changeset.context, [:private, :authorize?]) do - false -> false - _ -> true - end - - # Pass actor and authorize? to ensure proper authorization (User might have policies in future) - case Ash.get(Mv.Accounts.User, user_id, actor: actor, authorize?: authorize?) do + # This is an integrity check, not a user authorization check + # Use authorize?: false to bypass policies for this internal validation query + # This ensures the validation always works regardless of actor availability + # (consistent with MembershipFeeType destroy validations) + case Ash.get(Mv.Accounts.User, user_id, authorize?: false) do # User is free to be linked {:ok, %{member_id: nil}} -> :ok @@ -424,6 +410,9 @@ defmodule Mv.Membership.Member do # User is linked to a different member - prevent "stealing" {:error, field: :user, message: "User is already linked to another member"} + {:error, %Ash.Error.Query.NotFound{}} -> + {:error, field: :user, message: "User not found"} + {:error, _} -> {:error, field: :user, message: "User not found"} end diff --git a/lib/membership_fees/membership_fee_type.ex b/lib/membership_fees/membership_fee_type.ex index 64ca8f9..498ff75 100644 --- a/lib/membership_fees/membership_fee_type.ex +++ b/lib/membership_fees/membership_fee_type.ex @@ -85,13 +85,11 @@ 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() - + # Integrity check: count members without authorization (systemic operation) member_count = Mv.Membership.Member |> Ash.Query.filter(membership_fee_type_id == ^changeset.data.id) - |> Ash.count!(actor: system_actor) + |> Ash.count!(authorize?: false) if member_count > 0 do {:error, @@ -111,13 +109,11 @@ 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() - + # Integrity check: count cycles without authorization (systemic operation) cycle_count = Mv.MembershipFees.MembershipFeeCycle |> Ash.Query.filter(membership_fee_type_id == ^changeset.data.id) - |> Ash.count!(actor: system_actor) + |> Ash.count!(authorize?: false) if cycle_count > 0 do {:error, @@ -137,13 +133,11 @@ 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() - + # Integrity check: count settings without authorization (systemic operation) setting_count = Mv.Membership.Setting |> Ash.Query.filter(default_membership_fee_type_id == ^changeset.data.id) - |> Ash.count!(actor: system_actor) + |> Ash.count!(authorize?: false) if setting_count > 0 do {:error, -- 2.47.2 From c5a48d8801894508e22f2e932c28b6de29cc5ad9 Mon Sep 17 00:00:00 2001 From: Moritz Date: Sat, 24 Jan 2026 01:42:17 +0100 Subject: [PATCH 06/15] Fix tests: Remove duplicate actor keyword arguments --- test/accounts/user_member_deletion_test.exs | 4 ++-- .../accounts/user_member_relationship_test.exs | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/test/accounts/user_member_deletion_test.exs b/test/accounts/user_member_deletion_test.exs index f4aac89..feb7180 100644 --- a/test/accounts/user_member_deletion_test.exs +++ b/test/accounts/user_member_deletion_test.exs @@ -33,7 +33,7 @@ defmodule Mv.Accounts.UserMemberDeletionTest do # Verify the relationship is established {:ok, user_before_delete} = - Ash.get(Mv.Accounts.User, user.id, actor: actor, load: [:member], actor: actor) + Ash.get(Mv.Accounts.User, user.id, actor: actor, load: [:member]) assert user_before_delete.member_id == member.id assert user_before_delete.member.id == member.id @@ -43,7 +43,7 @@ defmodule Mv.Accounts.UserMemberDeletionTest do # Verify the user still exists but member_id is NULL {:ok, user_after_delete} = - Ash.get(Mv.Accounts.User, user.id, actor: actor, load: [:member], actor: actor) + Ash.get(Mv.Accounts.User, user.id, actor: actor, load: [:member]) assert user_after_delete.id == user.id assert user_after_delete.member_id == nil diff --git a/test/accounts/user_member_relationship_test.exs b/test/accounts/user_member_relationship_test.exs index 881f393..daafa1b 100644 --- a/test/accounts/user_member_relationship_test.exs +++ b/test/accounts/user_member_relationship_test.exs @@ -27,7 +27,7 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do # Load the relationship to test it {:ok, user_with_member} = - Ash.get(Mv.Accounts.User, user.id, actor: actor, load: [:member], actor: actor) + Ash.get(Mv.Accounts.User, user.id, actor: actor, load: [:member]) assert user_with_member.member == nil end @@ -39,7 +39,7 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do # Load the relationship to test it {:ok, member_with_user} = - Ash.get(Mv.Membership.Member, member.id, actor: actor, load: [:user], actor: actor) + Ash.get(Mv.Membership.Member, member.id, actor: actor, load: [:user]) assert member_with_user.user == nil end @@ -64,7 +64,7 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do # Load the relationship to test it {:ok, user_with_member} = - Ash.get(Mv.Accounts.User, user.id, actor: actor, load: [:member], actor: actor) + Ash.get(Mv.Accounts.User, user.id, actor: actor, load: [:member]) assert user_with_member.member.id == member.id end @@ -79,7 +79,7 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do # Load the relationship to test it {:ok, member_with_user} = - Ash.get(Mv.Membership.Member, member.id, actor: actor, load: [:user], actor: actor) + Ash.get(Mv.Membership.Member, member.id, actor: actor, load: [:user]) assert member_with_user.user.id == user.id end @@ -92,7 +92,7 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do # Load the relationship to test it {:ok, user_with_member} = - Ash.get(Mv.Accounts.User, updated_user.id, actor: actor, load: [:member], actor: actor) + Ash.get(Mv.Accounts.User, updated_user.id, actor: actor, load: [:member]) assert user_with_member.member.id == member.id end @@ -106,7 +106,7 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do # Load the relationship to test it {:ok, member_with_user} = - Ash.get(Mv.Membership.Member, member.id, actor: actor, load: [:user], actor: actor) + Ash.get(Mv.Membership.Member, member.id, actor: actor, load: [:user]) assert member_with_user.user.id == user.id end @@ -136,10 +136,10 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do # Load relationships {:ok, user_with_member} = - Ash.get(Mv.Accounts.User, user.id, actor: actor, load: [:member], actor: actor) + Ash.get(Mv.Accounts.User, user.id, actor: actor, load: [:member]) {:ok, member_with_user} = - Ash.get(Mv.Membership.Member, member.id, actor: actor, load: [:user], actor: actor) + Ash.get(Mv.Membership.Member, member.id, actor: actor, load: [:user]) assert user_with_member.member.id == member.id assert member_with_user.user.id == user.id @@ -154,7 +154,7 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do ) {:ok, member_with_user} = - Ash.get(Mv.Membership.Member, member.id, actor: actor, load: [:user], actor: actor) + Ash.get(Mv.Membership.Member, member.id, actor: actor, load: [:user]) assert member_with_user.user.id == user.id end -- 2.47.2 From b9d68a3417636e8f418c570981626354efb98841 Mon Sep 17 00:00:00 2001 From: Moritz Date: Sat, 24 Jan 2026 01:42:18 +0100 Subject: [PATCH 07/15] Fix test helpers: Use actor parameter correctly --- test/mv/accounts/user_policies_test.exs | 27 ++++++++++++--------- test/mv/membership/member_policies_test.exs | 15 +++++++----- test/support/fixtures.ex | 2 +- 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/test/mv/accounts/user_policies_test.exs b/test/mv/accounts/user_policies_test.exs index 3bbf54b..b7a0910 100644 --- a/test/mv/accounts/user_policies_test.exs +++ b/test/mv/accounts/user_policies_test.exs @@ -20,14 +20,17 @@ defmodule Mv.Accounts.UserPoliciesTest do 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(%{ - name: role_name, - description: "Test role for #{permission_set_name}", - permission_set_name: permission_set_name - }) do + case Authorization.create_role( + %{ + name: role_name, + description: "Test role for #{permission_set_name}", + permission_set_name: permission_set_name + }, + actor: actor + ) do {:ok, role} -> role {:error, error} -> raise "Failed to create role: #{inspect(error)}" end @@ -37,7 +40,7 @@ defmodule Mv.Accounts.UserPoliciesTest do # Returns user with role preloaded (required for authorization) 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} = @@ -340,10 +343,10 @@ defmodule Mv.Accounts.UserPoliciesTest do end describe "AshAuthentication bypass" do - test "register_with_password works without actor" do - # Registration should work without actor (AshAuthentication bypass) + test "register_with_password works with system actor" do + # Registration should work (AshAuthentication bypass in production) # Note: When directly calling Ash actions in tests, the AshAuthentication bypass - # may not be active, so we use system_actor + # may not be active, so we use system_actor to test the functionality system_actor = Mv.Helpers.SystemActor.get_system_actor() {:ok, user} = @@ -358,9 +361,9 @@ defmodule Mv.Accounts.UserPoliciesTest do end test "register_with_rauthy works with OIDC user_info" do - # OIDC registration should work (AshAuthentication bypass) + # OIDC registration should work (AshAuthentication bypass in production) # Note: When directly calling Ash actions in tests, the AshAuthentication bypass - # may not be active, so we use system_actor + # may not be active, so we use system_actor to test the functionality system_actor = Mv.Helpers.SystemActor.get_system_actor() user_info = %{ diff --git a/test/mv/membership/member_policies_test.exs b/test/mv/membership/member_policies_test.exs index cee8d97..0bbe1c1 100644 --- a/test/mv/membership/member_policies_test.exs +++ b/test/mv/membership/member_policies_test.exs @@ -22,14 +22,17 @@ defmodule Mv.Membership.MemberPoliciesTest do end # Helper to create a role with a specific permission set - defp create_role_with_permission_set(permission_set_name, _actor) 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(%{ - name: role_name, - description: "Test role for #{permission_set_name}", - permission_set_name: permission_set_name - }) do + case Authorization.create_role( + %{ + name: role_name, + description: "Test role for #{permission_set_name}", + permission_set_name: permission_set_name + }, + actor: actor + ) do {:ok, role} -> role {:error, error} -> raise "Failed to create role: #{inspect(error)}" end diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex index af67ffa..29726ef 100644 --- a/test/support/fixtures.ex +++ b/test/support/fixtures.ex @@ -178,7 +178,7 @@ defmodule Mv.Fixtures do |> Ash.update(actor: system_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: system_actor) user_with_role end -- 2.47.2 From d8187484b8180e8054e172e819254c09c06a4ba3 Mon Sep 17 00:00:00 2001 From: Moritz Date: Sat, 24 Jan 2026 01:42:20 +0100 Subject: [PATCH 08/15] Fix tests: Add missing actor parameters to Ash operations --- test/mv/authorization/actor_test.exs | 2 +- test/mv_web/live/membership_fee_type_live/index_test.exs | 4 +++- test/mv_web/live/role_live/show_test.exs | 2 +- test/mv_web/live/role_live_test.exs | 2 +- test/mv_web/member_live/form_membership_fee_type_test.exs | 4 +++- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/test/mv/authorization/actor_test.exs b/test/mv/authorization/actor_test.exs index 5d8266b..9fba86e 100644 --- a/test/mv/authorization/actor_test.exs +++ b/test/mv/authorization/actor_test.exs @@ -28,7 +28,7 @@ defmodule Mv.Authorization.ActorTest do |> Ash.create(actor: actor) # Load role - {:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts) + {:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor) # Should return as-is (no additional load) result = Actor.ensure_loaded(user_with_role) diff --git a/test/mv_web/live/membership_fee_type_live/index_test.exs b/test/mv_web/live/membership_fee_type_live/index_test.exs index 9c5ad55..58be2d3 100644 --- a/test/mv_web/live/membership_fee_type_live/index_test.exs +++ b/test/mv_web/live/membership_fee_type_live/index_test.exs @@ -16,6 +16,8 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest 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"), @@ -26,7 +28,7 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do MembershipFeeType |> Ash.Changeset.for_create(:create, attrs) - |> Ash.create!() + |> Ash.create!(actor: system_actor) end # Helper to create a member diff --git a/test/mv_web/live/role_live/show_test.exs b/test/mv_web/live/role_live/show_test.exs index 48edef6..4931058 100644 --- a/test/mv_web/live/role_live/show_test.exs +++ b/test/mv_web/live/role_live/show_test.exs @@ -79,7 +79,7 @@ defmodule MvWeb.RoleLive.ShowTest do |> 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) + user_with_role = Ash.load!(user, :role, domain: Mv.Accounts, actor: actor) # Store user with role in session for LiveView conn = conn_with_password_user(conn, user_with_role) diff --git a/test/mv_web/live/role_live_test.exs b/test/mv_web/live/role_live_test.exs index 257dd3e..d3db337 100644 --- a/test/mv_web/live/role_live_test.exs +++ b/test/mv_web/live/role_live_test.exs @@ -70,7 +70,7 @@ defmodule MvWeb.RoleLiveTest do |> 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) + user_with_role = Ash.load!(user, :role, domain: Mv.Accounts, actor: actor) # Store user with role in session for LiveView conn = conn_with_password_user(conn, user_with_role) diff --git a/test/mv_web/member_live/form_membership_fee_type_test.exs b/test/mv_web/member_live/form_membership_fee_type_test.exs index 93f4b6a..a4d3673 100644 --- a/test/mv_web/member_live/form_membership_fee_type_test.exs +++ b/test/mv_web/member_live/form_membership_fee_type_test.exs @@ -212,6 +212,8 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do end test "union/typed values roundtrip correctly", %{conn: conn, current_user: admin_user} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + # Create date custom field custom_field = Mv.Membership.CustomField @@ -220,7 +222,7 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do value_type: :date, required: false }) - |> Ash.create!() + |> Ash.create!(actor: system_actor) fee_type = create_fee_type(%{interval: :yearly}) -- 2.47.2 From bebd7f6fe2917241d51e5ae20be9e55de2b37433 Mon Sep 17 00:00:00 2001 From: Moritz Date: Sat, 24 Jan 2026 01:42:22 +0100 Subject: [PATCH 09/15] Fix tests: Remove redundant system_actor and update test descriptions --- .../authorization/checks/has_permission_fail_closed_test.exs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/mv/authorization/checks/has_permission_fail_closed_test.exs b/test/mv/authorization/checks/has_permission_fail_closed_test.exs index 3e337c9..36ddbd2 100644 --- a/test/mv/authorization/checks/has_permission_fail_closed_test.exs +++ b/test/mv/authorization/checks/has_permission_fail_closed_test.exs @@ -36,10 +36,8 @@ defmodule Mv.Authorization.Checks.HasPermissionFailClosedTest do |> Ash.Query.new() |> Ash.Query.filter_input(deny_filter) - system_actor = Mv.Helpers.SystemActor.get_system_actor() - {:ok, results} = - Ash.read(query, domain: Mv.Membership, authorize?: false, actor: system_actor) + Ash.read(query, domain: Mv.Membership, authorize?: false) # Assert: deny-filter must match nothing assert results == [] -- 2.47.2 From 195f1dbc88fb8655defaa307b37af68006aa6f2e Mon Sep 17 00:00:00 2001 From: Moritz Date: Sat, 24 Jan 2026 02:06:21 +0100 Subject: [PATCH 10/15] Fix test db connections: increase pool size and timeout --- config/test.exs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/config/test.exs b/config/test.exs index b48c408..b47c764 100644 --- a/config/test.exs +++ b/config/test.exs @@ -12,7 +12,10 @@ config :mv, Mv.Repo, port: System.get_env("TEST_POSTGRES_PORT", "5000"), database: "mv_test#{System.get_env("MIX_TEST_PARTITION")}", pool: Ecto.Adapters.SQL.Sandbox, - pool_size: System.schedulers_online() * 4 + pool_size: System.schedulers_online() * 8, + queue_target: 5000, + queue_interval: 1000, + timeout: 30_000 # We don't run a server during test. If one is required, # you can enable the server option below. -- 2.47.2 From fcca4b0b895014dd8ea750b77f97528bef16625e Mon Sep 17 00:00:00 2001 From: Moritz Date: Sat, 24 Jan 2026 02:10:08 +0100 Subject: [PATCH 11/15] Use admin_user instead of system_actor in LiveView tests --- .../membership_fee_type_live/index_test.exs | 41 +++++---- .../form_membership_fee_type_test.exs | 90 +++++++++---------- 2 files changed, 67 insertions(+), 64 deletions(-) diff --git a/test/mv_web/live/membership_fee_type_live/index_test.exs b/test/mv_web/live/membership_fee_type_live/index_test.exs index 58be2d3..302814d 100644 --- a/test/mv_web/live/membership_fee_type_live/index_test.exs +++ b/test/mv_web/live/membership_fee_type_live/index_test.exs @@ -15,9 +15,8 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do # No custom setup needed # Helper to create a membership fee type - defp create_fee_type(attrs) do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - + # Uses admin_user to test permissions (UI-/Permissions-nah) + defp create_fee_type(attrs, admin_user) do default_attrs = %{ name: "Test Fee Type #{System.unique_integer([:positive])}", amount: Decimal.new("50.00"), @@ -28,7 +27,7 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do MembershipFeeType |> Ash.Changeset.for_create(:create, attrs) - |> Ash.create!(actor: system_actor) + |> Ash.create!(actor: admin_user) end # Helper to create a member @@ -50,12 +49,21 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do end describe "list display" do - test "displays all membership fee types with correct data", %{conn: conn} do + test "displays all membership fee types with correct data", %{ + conn: conn, + current_user: admin_user + } do _fee_type1 = - create_fee_type(%{name: "Regular", amount: Decimal.new("60.00"), interval: :yearly}) + create_fee_type( + %{name: "Regular", amount: Decimal.new("60.00"), interval: :yearly}, + admin_user + ) _fee_type2 = - create_fee_type(%{name: "Reduced", amount: Decimal.new("30.00"), interval: :yearly}) + create_fee_type( + %{name: "Reduced", amount: Decimal.new("30.00"), interval: :yearly}, + admin_user + ) {:ok, _view, html} = live(conn, "/membership_fee_types") @@ -67,7 +75,7 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do end test "member count column shows correct count", %{conn: conn, current_user: admin_user} do - fee_type = create_fee_type(%{interval: :yearly}) + fee_type = create_fee_type(%{interval: :yearly}, admin_user) # Create 3 members with this fee type Enum.each(1..3, fn _ -> @@ -90,8 +98,8 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do assert to == "/membership_fee_types/new" end - test "edit button per row navigates to edit form", %{conn: conn} do - fee_type = create_fee_type(%{interval: :yearly}) + test "edit button per row navigates to edit form", %{conn: conn, current_user: admin_user} do + fee_type = create_fee_type(%{interval: :yearly}, admin_user) {:ok, view, _html} = live(conn, "/membership_fee_types") @@ -106,7 +114,7 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do describe "delete functionality" do test "delete button disabled if type is in use", %{conn: conn, current_user: admin_user} do - fee_type = create_fee_type(%{interval: :yearly}) + fee_type = create_fee_type(%{interval: :yearly}, admin_user) create_member(%{membership_fee_type_id: fee_type.id}, admin_user) {:ok, _view, html} = live(conn, "/membership_fee_types") @@ -115,8 +123,8 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do assert html =~ "disabled" || html =~ "cursor-not-allowed" end - test "delete button works if type is not in use", %{conn: conn} do - fee_type = create_fee_type(%{interval: :yearly}) + test "delete button works if type is not in use", %{conn: conn, current_user: admin_user} do + fee_type = create_fee_type(%{interval: :yearly}, admin_user) # No members assigned {:ok, view, _html} = live(conn, "/membership_fee_types") @@ -126,9 +134,12 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do |> element("button[phx-click='delete'][phx-value-id='#{fee_type.id}']") |> render_click() - # Type should be deleted + # Type should be deleted - use admin_user to test permissions assert {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{}]}} = - Ash.get(MembershipFeeType, fee_type.id, domain: Mv.MembershipFees) + Ash.get(MembershipFeeType, fee_type.id, + domain: Mv.MembershipFees, + actor: admin_user + ) end end diff --git a/test/mv_web/member_live/form_membership_fee_type_test.exs b/test/mv_web/member_live/form_membership_fee_type_test.exs index a4d3673..911a4ce 100644 --- a/test/mv_web/member_live/form_membership_fee_type_test.exs +++ b/test/mv_web/member_live/form_membership_fee_type_test.exs @@ -12,9 +12,8 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do require Ash.Query # Helper to create a membership fee type - defp create_fee_type(attrs) do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - + # Uses admin_user to test permissions (UI-/Permissions-nah) + defp create_fee_type(attrs, admin_user) do default_attrs = %{ name: "Test Fee Type #{System.unique_integer([:positive])}", amount: Decimal.new("50.00"), @@ -25,13 +24,12 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do MembershipFeeType |> Ash.Changeset.for_create(:create, attrs) - |> Ash.create!(actor: system_actor) + |> Ash.create!(actor: admin_user) end # Helper to create a member - defp create_member(attrs) do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - + # Uses admin_user to test permissions (UI-/Permissions-nah) + defp create_member(attrs, admin_user) do default_attrs = %{ first_name: "Test", last_name: "Member", @@ -42,7 +40,7 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do Member |> Ash.Changeset.for_create(:create_member, attrs) - |> Ash.create!(actor: system_actor) + |> Ash.create!(actor: admin_user) end describe "membership fee type dropdown" do @@ -54,9 +52,9 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do html =~ "Beitragsart" end - test "shows available types", %{conn: conn} do - _fee_type1 = create_fee_type(%{name: "Type 1", interval: :yearly}) - _fee_type2 = create_fee_type(%{name: "Type 2", interval: :yearly}) + test "shows available types", %{conn: conn, current_user: admin_user} do + _fee_type1 = create_fee_type(%{name: "Type 1", interval: :yearly}, admin_user) + _fee_type2 = create_fee_type(%{name: "Type 2", interval: :yearly}, admin_user) {:ok, _view, html} = live(conn, "/members/new") @@ -64,11 +62,14 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do assert html =~ "Type 2" end - test "filters to same interval types if member has type", %{conn: conn} do - yearly_type = create_fee_type(%{name: "Yearly Type", interval: :yearly}) - _monthly_type = create_fee_type(%{name: "Monthly Type", interval: :monthly}) + test "filters to same interval types if member has type", %{ + conn: conn, + current_user: admin_user + } do + yearly_type = create_fee_type(%{name: "Yearly Type", interval: :yearly}, admin_user) + _monthly_type = create_fee_type(%{name: "Monthly Type", interval: :monthly}, admin_user) - member = create_member(%{membership_fee_type_id: yearly_type.id}) + member = create_member(%{membership_fee_type_id: yearly_type.id}, admin_user) {:ok, _view, html} = live(conn, "/members/#{member.id}/edit") @@ -77,11 +78,11 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do refute html =~ "Monthly Type" end - test "shows warning if different interval selected", %{conn: conn} do - yearly_type = create_fee_type(%{name: "Yearly Type", interval: :yearly}) - monthly_type = create_fee_type(%{name: "Monthly Type", interval: :monthly}) + test "shows warning if different interval selected", %{conn: conn, current_user: admin_user} do + yearly_type = create_fee_type(%{name: "Yearly Type", interval: :yearly}, admin_user) + monthly_type = create_fee_type(%{name: "Monthly Type", interval: :monthly}, admin_user) - member = create_member(%{membership_fee_type_id: yearly_type.id}) + member = create_member(%{membership_fee_type_id: yearly_type.id}, admin_user) {:ok, _view, html} = live(conn, "/members/#{member.id}/edit") @@ -92,11 +93,11 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do assert html =~ yearly_type.id end - test "warning cleared if same interval selected", %{conn: conn} do - yearly_type1 = create_fee_type(%{name: "Yearly Type 1", interval: :yearly}) - yearly_type2 = create_fee_type(%{name: "Yearly Type 2", interval: :yearly}) + test "warning cleared if same interval selected", %{conn: conn, current_user: admin_user} do + yearly_type1 = create_fee_type(%{name: "Yearly Type 1", interval: :yearly}, admin_user) + yearly_type2 = create_fee_type(%{name: "Yearly Type 2", interval: :yearly}, admin_user) - member = create_member(%{membership_fee_type_id: yearly_type1.id}) + member = create_member(%{membership_fee_type_id: yearly_type1.id}, admin_user) {:ok, view, _html} = live(conn, "/members/#{member.id}/edit") @@ -109,8 +110,8 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do refute html =~ "Warning" || html =~ "Warnung" end - test "form saves with selected membership fee type", %{conn: conn} do - fee_type = create_fee_type(%{interval: :yearly}) + test "form saves with selected membership fee type", %{conn: conn, current_user: admin_user} do + fee_type = create_fee_type(%{interval: :yearly}, admin_user) {:ok, view, _html} = live(conn, "/members/new") @@ -126,29 +127,26 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do |> form("#member-form", form_data) |> render_submit() - # Verify member was created with fee type - system_actor = Mv.Helpers.SystemActor.get_system_actor() - + # Verify member was created with fee type - use admin_user to test permissions member = Member |> Ash.Query.filter(email == ^form_data["member[email]"]) - |> Ash.read_one!(actor: system_actor) + |> Ash.read_one!(actor: admin_user) assert member.membership_fee_type_id == fee_type.id end - test "new members get default membership fee type", %{conn: conn} do + test "new members get default membership fee type", %{conn: conn, current_user: admin_user} do # Set default fee type in settings - fee_type = create_fee_type(%{interval: :yearly}) + fee_type = create_fee_type(%{interval: :yearly}, admin_user) - 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!(actor: system_actor) + |> Ash.update!(actor: admin_user) {:ok, view, _html} = live(conn, "/members/new") @@ -163,9 +161,7 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do conn: conn, current_user: admin_user } do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - - # Create custom field + # Create custom field - use admin_user to test permissions custom_field = Mv.Membership.CustomField |> Ash.Changeset.for_create(:create, %{ @@ -173,11 +169,11 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do value_type: :string, required: false }) - |> Ash.create!(actor: system_actor) + |> Ash.create!(actor: admin_user) # Create two fee types with same interval - fee_type1 = create_fee_type(%{name: "Type 1", interval: :yearly}) - fee_type2 = create_fee_type(%{name: "Type 2", interval: :yearly}) + fee_type1 = create_fee_type(%{name: "Type 1", interval: :yearly}, admin_user) + fee_type2 = create_fee_type(%{name: "Type 2", interval: :yearly}, admin_user) # Create member with fee type 1 and custom field value member = @@ -212,9 +208,7 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do end test "union/typed values roundtrip correctly", %{conn: conn, current_user: admin_user} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - - # Create date custom field + # Create date custom field - use admin_user to test permissions custom_field = Mv.Membership.CustomField |> Ash.Changeset.for_create(:create, %{ @@ -222,9 +216,9 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do value_type: :date, required: false }) - |> Ash.create!(actor: system_actor) + |> Ash.create!(actor: admin_user) - fee_type = create_fee_type(%{interval: :yearly}) + fee_type = create_fee_type(%{interval: :yearly}, admin_user) # Create member with date custom field value member = @@ -261,9 +255,7 @@ 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 + # Create custom field - use admin_user to test permissions custom_field = Mv.Membership.CustomField |> Ash.Changeset.for_create(:create, %{ @@ -271,9 +263,9 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do value_type: :string, required: false }) - |> Ash.create!(actor: system_actor) + |> Ash.create!(actor: admin_user) - fee_type = create_fee_type(%{interval: :yearly}) + fee_type = create_fee_type(%{interval: :yearly}, admin_user) # Create member with custom field value member = -- 2.47.2 From 15a7c615d68838ee9e2dca6ae04bb346fc5380a0 Mon Sep 17 00:00:00 2001 From: Moritz Date: Sat, 24 Jan 2026 02:39:06 +0100 Subject: [PATCH 12/15] Fix rebase conflict: Add actor parameter to helper functions in index_test.exs --- test/mv_web/member_live/index_test.exs | 259 +++++++++++++++---------- 1 file changed, 157 insertions(+), 102 deletions(-) diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index bf412d8..0f3d03b 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -7,7 +7,7 @@ defmodule MvWeb.MemberLive.IndexTest do alias Mv.MembershipFees.MembershipFeeCycle # Helper to create a membership fee type (shared across all tests) - 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"), @@ -18,18 +18,18 @@ defmodule MvWeb.MemberLive.IndexTest do MembershipFeeType |> Ash.Changeset.for_create(:create, attrs) - |> Ash.create!() + |> Ash.create!(actor: actor) end # Helper to create a cycle (shared across all tests) - defp create_cycle(member, fee_type, attrs) do + 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!() + |> Ash.read!(actor: actor) - Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle) end) + Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle, actor: actor) end) default_attrs = %{ cycle_start: ~D[2023-01-01], @@ -43,7 +43,7 @@ defmodule MvWeb.MemberLive.IndexTest do MembershipFeeCycle |> Ash.Changeset.for_create(:create, attrs) - |> Ash.create!() + |> Ash.create!(actor: actor) end test "shows translated title in German", %{conn: conn} do @@ -522,25 +522,7 @@ 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 + # Helper to create a member (only used in this describe block) defp create_member(attrs, actor) do default_attrs = %{ first_name: "Test", @@ -555,31 +537,6 @@ defmodule MvWeb.MemberLive.IndexTest do |> 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) @@ -1164,7 +1121,7 @@ defmodule MvWeb.MemberLive.IndexTest do end # Helper to create a member with a boolean custom field value - defp create_member_with_boolean_value(member_attrs, custom_field, value) do + defp create_member_with_boolean_value(member_attrs, custom_field, value, actor) do {:ok, member} = Mv.Membership.Member |> Ash.Changeset.for_create( @@ -1176,7 +1133,7 @@ defmodule MvWeb.MemberLive.IndexTest do } |> Map.merge(member_attrs) ) - |> Ash.create() + |> Ash.create(actor: actor) {:ok, _cfv} = Mv.Membership.CustomFieldValue @@ -1185,17 +1142,18 @@ defmodule MvWeb.MemberLive.IndexTest do custom_field_id: custom_field.id, value: %{"_union_type" => "boolean", "_union_value" => value} }) - |> Ash.create() + |> Ash.create(actor: actor) # Reload member with custom field values member - |> Ash.load!(:custom_field_values) + |> Ash.load!(:custom_field_values, actor: actor) end # Tests for get_boolean_custom_field_value/2 test "get_boolean_custom_field_value extracts true from Ash.Union format", %{conn: _conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() boolean_field = create_boolean_custom_field() - member = create_member_with_boolean_value(%{}, boolean_field, true) + member = create_member_with_boolean_value(%{}, boolean_field, true, system_actor) # Test the function (will fail until implemented) result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field) @@ -1204,8 +1162,9 @@ defmodule MvWeb.MemberLive.IndexTest do end test "get_boolean_custom_field_value extracts false from Ash.Union format", %{conn: _conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() boolean_field = create_boolean_custom_field() - member = create_member_with_boolean_value(%{}, boolean_field, false) + member = create_member_with_boolean_value(%{}, boolean_field, false, system_actor) result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field) @@ -1214,6 +1173,7 @@ defmodule MvWeb.MemberLive.IndexTest do test "get_boolean_custom_field_value extracts true from map format with _union_type and _union_value keys", %{conn: _conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() boolean_field = create_boolean_custom_field() {:ok, member} = @@ -1223,7 +1183,7 @@ defmodule MvWeb.MemberLive.IndexTest do last_name: "Member", email: "test.member.#{System.unique_integer([:positive])}@example.com" }) - |> Ash.create() + |> Ash.create(actor: system_actor) # Create CustomFieldValue with map format (Ash expects _union_type and _union_value) {:ok, _cfv} = @@ -1233,10 +1193,10 @@ defmodule MvWeb.MemberLive.IndexTest do custom_field_id: boolean_field.id, value: %{"_union_type" => "boolean", "_union_value" => true} }) - |> Ash.create() + |> Ash.create(actor: system_actor) # Reload member with custom field values - member = member |> Ash.load!(:custom_field_values) + member = member |> Ash.load!(:custom_field_values, actor: system_actor) result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field) @@ -1246,6 +1206,7 @@ defmodule MvWeb.MemberLive.IndexTest do test "get_boolean_custom_field_value returns nil when no CustomFieldValue exists", %{ conn: _conn } do + system_actor = Mv.Helpers.SystemActor.get_system_actor() boolean_field = create_boolean_custom_field() {:ok, member} = @@ -1255,10 +1216,10 @@ defmodule MvWeb.MemberLive.IndexTest do last_name: "Member", email: "test.member.#{System.unique_integer([:positive])}@example.com" }) - |> Ash.create() + |> Ash.create(actor: system_actor) # Member has no custom field value for this field - member = member |> Ash.load!(:custom_field_values) + member = member |> Ash.load!(:custom_field_values, actor: system_actor) result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field) @@ -1268,6 +1229,7 @@ defmodule MvWeb.MemberLive.IndexTest do test "get_boolean_custom_field_value returns nil when CustomFieldValue has nil value", %{ conn: _conn } do + system_actor = Mv.Helpers.SystemActor.get_system_actor() boolean_field = create_boolean_custom_field() {:ok, member} = @@ -1277,7 +1239,7 @@ defmodule MvWeb.MemberLive.IndexTest do last_name: "Member", email: "test.member.#{System.unique_integer([:positive])}@example.com" }) - |> Ash.create() + |> Ash.create(actor: system_actor) # Create CustomFieldValue with nil value (edge case) {:ok, _cfv} = @@ -1287,9 +1249,9 @@ defmodule MvWeb.MemberLive.IndexTest do custom_field_id: boolean_field.id, value: nil }) - |> Ash.create() + |> Ash.create(actor: system_actor) - member = member |> Ash.load!(:custom_field_values) + member = member |> Ash.load!(:custom_field_values, actor: system_actor) result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field) @@ -1299,6 +1261,7 @@ defmodule MvWeb.MemberLive.IndexTest do test "get_boolean_custom_field_value returns nil for non-boolean CustomFieldValue", %{ conn: _conn } do + system_actor = Mv.Helpers.SystemActor.get_system_actor() string_field = create_string_custom_field() boolean_field = create_boolean_custom_field() @@ -1309,7 +1272,7 @@ defmodule MvWeb.MemberLive.IndexTest do last_name: "Member", email: "test.member.#{System.unique_integer([:positive])}@example.com" }) - |> Ash.create() + |> Ash.create(actor: system_actor) # Create string custom field value (not boolean) {:ok, _cfv} = @@ -1319,9 +1282,9 @@ defmodule MvWeb.MemberLive.IndexTest do custom_field_id: string_field.id, value: %{"_union_type" => "string", "_union_value" => "test"} }) - |> Ash.create() + |> Ash.create(actor: system_actor) - member = member |> Ash.load!(:custom_field_values) + member = member |> Ash.load!(:custom_field_values, actor: system_actor) # Try to get boolean value from string field - should return nil result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field) @@ -1332,13 +1295,24 @@ defmodule MvWeb.MemberLive.IndexTest do # Tests for apply_boolean_custom_field_filters/2 test "apply_boolean_custom_field_filters filters members with true value and excludes false/without values", %{conn: _conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() boolean_field = create_boolean_custom_field() member_with_true = - create_member_with_boolean_value(%{first_name: "TrueMember"}, boolean_field, true) + create_member_with_boolean_value( + %{first_name: "TrueMember"}, + boolean_field, + true, + system_actor + ) member_with_false = - create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false) + create_member_with_boolean_value( + %{first_name: "FalseMember"}, + boolean_field, + false, + system_actor + ) {:ok, member_without_value} = Mv.Membership.Member @@ -1347,7 +1321,7 @@ defmodule MvWeb.MemberLive.IndexTest do last_name: "Member", email: "novalue.member.#{System.unique_integer([:positive])}@example.com" }) - |> Ash.create() + |> Ash.create(actor: system_actor) member_without_value = member_without_value |> Ash.load!(:custom_field_values) @@ -1370,13 +1344,24 @@ defmodule MvWeb.MemberLive.IndexTest do test "apply_boolean_custom_field_filters filters members with false value and excludes true/without values", %{conn: _conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() boolean_field = create_boolean_custom_field() member_with_true = - create_member_with_boolean_value(%{first_name: "TrueMember"}, boolean_field, true) + create_member_with_boolean_value( + %{first_name: "TrueMember"}, + boolean_field, + true, + system_actor + ) member_with_false = - create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false) + create_member_with_boolean_value( + %{first_name: "FalseMember"}, + boolean_field, + false, + system_actor + ) {:ok, member_without_value} = Mv.Membership.Member @@ -1385,9 +1370,10 @@ defmodule MvWeb.MemberLive.IndexTest do last_name: "Member", email: "novalue.member.#{System.unique_integer([:positive])}@example.com" }) - |> Ash.create() + |> Ash.create(actor: system_actor) - member_without_value = member_without_value |> Ash.load!(:custom_field_values) + member_without_value = + member_without_value |> Ash.load!(:custom_field_values, actor: system_actor) members = [member_with_true, member_with_false, member_without_value] filters = %{to_string(boolean_field.id) => false} @@ -1409,10 +1395,24 @@ defmodule MvWeb.MemberLive.IndexTest do test "apply_boolean_custom_field_filters returns all members when filter map is empty", %{ conn: _conn } do + system_actor = Mv.Helpers.SystemActor.get_system_actor() boolean_field = create_boolean_custom_field() - member1 = create_member_with_boolean_value(%{first_name: "Member1"}, boolean_field, true) - member2 = create_member_with_boolean_value(%{first_name: "Member2"}, boolean_field, false) + member1 = + create_member_with_boolean_value( + %{first_name: "Member1"}, + boolean_field, + true, + system_actor + ) + + member2 = + create_member_with_boolean_value( + %{first_name: "Member2"}, + boolean_field, + false, + system_actor + ) members = [member1, member2] filters = %{} @@ -1435,6 +1435,7 @@ defmodule MvWeb.MemberLive.IndexTest do test "apply_boolean_custom_field_filters applies multiple filters with AND logic", %{ conn: _conn } do + system_actor = Mv.Helpers.SystemActor.get_system_actor() boolean_field1 = create_boolean_custom_field(%{name: "Field1"}) boolean_field2 = create_boolean_custom_field(%{name: "Field2"}) @@ -1446,7 +1447,7 @@ defmodule MvWeb.MemberLive.IndexTest do last_name: "Member", email: "bothtrue.member.#{System.unique_integer([:positive])}@example.com" }) - |> Ash.create() + |> Ash.create(actor: system_actor) {:ok, _cfv1} = Mv.Membership.CustomFieldValue @@ -1455,7 +1456,7 @@ defmodule MvWeb.MemberLive.IndexTest do custom_field_id: boolean_field1.id, value: %{"_union_type" => "boolean", "_union_value" => true} }) - |> Ash.create() + |> Ash.create(actor: system_actor) {:ok, _cfv2} = Mv.Membership.CustomFieldValue @@ -1464,9 +1465,9 @@ defmodule MvWeb.MemberLive.IndexTest do custom_field_id: boolean_field2.id, value: %{"_union_type" => "boolean", "_union_value" => true} }) - |> Ash.create() + |> Ash.create(actor: system_actor) - member_both_true = member_both_true |> Ash.load!(:custom_field_values) + member_both_true = member_both_true |> Ash.load!(:custom_field_values, actor: system_actor) # Member with field1 = true, field2 = false {:ok, member_mixed} = @@ -1476,7 +1477,7 @@ defmodule MvWeb.MemberLive.IndexTest do last_name: "Member", email: "mixed.member.#{System.unique_integer([:positive])}@example.com" }) - |> Ash.create() + |> Ash.create(actor: system_actor) {:ok, _cfv3} = Mv.Membership.CustomFieldValue @@ -1485,7 +1486,7 @@ defmodule MvWeb.MemberLive.IndexTest do custom_field_id: boolean_field1.id, value: %{"_union_type" => "boolean", "_union_value" => true} }) - |> Ash.create() + |> Ash.create(actor: system_actor) {:ok, _cfv4} = Mv.Membership.CustomFieldValue @@ -1494,9 +1495,9 @@ defmodule MvWeb.MemberLive.IndexTest do custom_field_id: boolean_field2.id, value: %{"_union_type" => "boolean", "_union_value" => false} }) - |> Ash.create() + |> Ash.create(actor: system_actor) - member_mixed = member_mixed |> Ash.load!(:custom_field_values) + member_mixed = member_mixed |> Ash.load!(:custom_field_values, actor: system_actor) members = [member_both_true, member_mixed] @@ -1522,10 +1523,17 @@ defmodule MvWeb.MemberLive.IndexTest do test "apply_boolean_custom_field_filters ignores filter with non-existent custom field ID", %{ conn: _conn } do + system_actor = Mv.Helpers.SystemActor.get_system_actor() boolean_field = create_boolean_custom_field() fake_id = Ecto.UUID.generate() - member = create_member_with_boolean_value(%{first_name: "Member"}, boolean_field, true) + member = + create_member_with_boolean_value( + %{first_name: "Member"}, + boolean_field, + true, + system_actor + ) members = [member] filters = %{fake_id => true} @@ -1545,14 +1553,25 @@ defmodule MvWeb.MemberLive.IndexTest do # Integration tests for boolean custom field filters in load_members test "boolean filter integration filters members by boolean custom field value via URL parameter", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() conn = conn_with_oidc_user(conn) boolean_field = create_boolean_custom_field() _member_with_true = - create_member_with_boolean_value(%{first_name: "TrueMember"}, boolean_field, true) + create_member_with_boolean_value( + %{first_name: "TrueMember"}, + boolean_field, + true, + system_actor + ) _member_with_false = - create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false) + create_member_with_boolean_value( + %{first_name: "FalseMember"}, + boolean_field, + false, + system_actor + ) {:ok, _member_without_value} = Mv.Membership.Member @@ -1561,7 +1580,7 @@ defmodule MvWeb.MemberLive.IndexTest do last_name: "Member", email: "novalue.member.#{System.unique_integer([:positive])}@example.com" }) - |> Ash.create() + |> Ash.create(actor: system_actor) # Test true filter {:ok, _view, html_true} = @@ -1581,9 +1600,10 @@ defmodule MvWeb.MemberLive.IndexTest do end test "boolean filter integration works together with cycle_status_filter", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() conn = conn_with_oidc_user(conn) boolean_field = create_boolean_custom_field() - 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) @@ -1596,7 +1616,7 @@ defmodule MvWeb.MemberLive.IndexTest do email: "paidtrue.member.#{System.unique_integer([:positive])}@example.com", membership_fee_type_id: fee_type.id }) - |> Ash.create() + |> Ash.create(actor: system_actor) {:ok, _cfv} = Mv.Membership.CustomFieldValue @@ -1605,9 +1625,14 @@ defmodule MvWeb.MemberLive.IndexTest do custom_field_id: boolean_field.id, value: %{"_union_type" => "boolean", "_union_value" => true} }) - |> Ash.create() + |> Ash.create(actor: system_actor) - create_cycle(member_paid_true, fee_type, %{cycle_start: last_year_start, status: :paid}) + create_cycle( + member_paid_true, + fee_type, + %{cycle_start: last_year_start, status: :paid}, + system_actor + ) # Member with true boolean value but unpaid status {:ok, member_unpaid_true} = @@ -1618,7 +1643,7 @@ defmodule MvWeb.MemberLive.IndexTest do email: "unpaidtrue.member.#{System.unique_integer([:positive])}@example.com", membership_fee_type_id: fee_type.id }) - |> Ash.create() + |> Ash.create(actor: system_actor) {:ok, _cfv2} = Mv.Membership.CustomFieldValue @@ -1627,9 +1652,14 @@ defmodule MvWeb.MemberLive.IndexTest do custom_field_id: boolean_field.id, value: %{"_union_type" => "boolean", "_union_value" => true} }) - |> Ash.create() + |> Ash.create(actor: system_actor) - create_cycle(member_unpaid_true, fee_type, %{cycle_start: last_year_start, status: :unpaid}) + create_cycle( + member_unpaid_true, + fee_type, + %{cycle_start: last_year_start, status: :unpaid}, + system_actor + ) # Test both filters together {:ok, _view, html} = @@ -1641,14 +1671,25 @@ defmodule MvWeb.MemberLive.IndexTest do end test "boolean filter integration works together with search query", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() conn = conn_with_oidc_user(conn) boolean_field = create_boolean_custom_field() _member_with_true = - create_member_with_boolean_value(%{first_name: "TrueMember"}, boolean_field, true) + create_member_with_boolean_value( + %{first_name: "TrueMember"}, + boolean_field, + true, + system_actor + ) _member_with_false = - create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false) + create_member_with_boolean_value( + %{first_name: "FalseMember"}, + boolean_field, + false, + system_actor + ) # Test search + boolean filter {:ok, _view, html} = @@ -1660,16 +1701,27 @@ defmodule MvWeb.MemberLive.IndexTest do end test "boolean filter works even when custom field is not visible in overview", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() conn = conn_with_oidc_user(conn) # Create boolean field with show_in_overview: false boolean_field = create_boolean_custom_field(%{show_in_overview: false}) _member_with_true = - create_member_with_boolean_value(%{first_name: "TrueMember"}, boolean_field, true) + create_member_with_boolean_value( + %{first_name: "TrueMember"}, + boolean_field, + true, + system_actor + ) _member_with_false = - create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false) + create_member_with_boolean_value( + %{first_name: "FalseMember"}, + boolean_field, + false, + system_actor + ) {:ok, _member_without_value} = Mv.Membership.Member @@ -1678,7 +1730,7 @@ defmodule MvWeb.MemberLive.IndexTest do last_name: "Member", email: "novalue.member.#{System.unique_integer([:positive])}@example.com" }) - |> Ash.create() + |> Ash.create(actor: system_actor) # Test that filter works even though field is not visible in overview {:ok, _view, html_true} = @@ -1723,6 +1775,7 @@ defmodule MvWeb.MemberLive.IndexTest do end test "boolean filter performance with 150 members", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() conn = conn_with_oidc_user(conn) boolean_field = create_boolean_custom_field() @@ -1735,7 +1788,8 @@ defmodule MvWeb.MemberLive.IndexTest do email: "truemember#{i}@example.com" }, boolean_field, - true + true, + system_actor ) end) @@ -1747,7 +1801,8 @@ defmodule MvWeb.MemberLive.IndexTest do email: "falsemember#{i}@example.com" }, boolean_field, - false + false, + system_actor ) end) -- 2.47.2 From 71c13d0ac0a2fda227a4941a706d3aef97a0a045 Mon Sep 17 00:00:00 2001 From: Moritz Date: Sat, 24 Jan 2026 08:38:33 +0100 Subject: [PATCH 13/15] Fix missing actor parameters and restore AshAuthentication bypass tests --- .../member_cycle_calculations_test.exs | 30 +++++++------ .../member_type_change_integration_test.exs | 8 ++-- .../membership_fee_cycle_test.exs | 12 ++--- test/mv/accounts/user_policies_test.exs | 45 +++++++++---------- test/mv_web/member_live/index_test.exs | 3 +- 5 files changed, 50 insertions(+), 48 deletions(-) diff --git a/test/membership/member_cycle_calculations_test.exs b/test/membership/member_cycle_calculations_test.exs index 030aa8b..ea7f378 100644 --- a/test/membership/member_cycle_calculations_test.exs +++ b/test/membership/member_cycle_calculations_test.exs @@ -81,7 +81,7 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do actor ) - member = Ash.load!(member, :current_cycle_status) + member = Ash.load!(member, :current_cycle_status, actor: actor) assert member.current_cycle_status == :paid end @@ -100,7 +100,7 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do actor ) - member = Ash.load!(member, :current_cycle_status) + member = Ash.load!(member, :current_cycle_status, actor: actor) assert member.current_cycle_status == nil end @@ -108,7 +108,7 @@ defmodule Mv.Membership.MemberCycleCalculationsTest 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) + member = Ash.load!(member, :current_cycle_status, actor: actor) assert member.current_cycle_status == nil end @@ -130,7 +130,7 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do actor ) - member = Ash.load!(member, :current_cycle_status) + member = Ash.load!(member, :current_cycle_status, actor: actor) assert member.current_cycle_status == :unpaid end end @@ -176,7 +176,7 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do actor ) - member = Ash.load!(member, :last_cycle_status) + member = Ash.load!(member, :last_cycle_status, actor: actor) # Should return status of 2023 (last completed) assert member.last_cycle_status == :unpaid end @@ -199,7 +199,7 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do actor ) - member = Ash.load!(member, :last_cycle_status) + member = Ash.load!(member, :last_cycle_status, actor: actor) assert member.last_cycle_status == nil end @@ -207,7 +207,7 @@ defmodule Mv.Membership.MemberCycleCalculationsTest 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) + member = Ash.load!(member, :last_cycle_status, actor: actor) assert member.last_cycle_status == nil end @@ -240,7 +240,7 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do actor ) - member = Ash.load!(member, :last_cycle_status) + member = Ash.load!(member, :last_cycle_status, actor: actor) # Should return status of last month (last completed) assert member.last_cycle_status == :paid end @@ -309,7 +309,7 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do ) end - member = Ash.load!(member, :overdue_count) + member = Ash.load!(member, :overdue_count, actor: actor) # Should only count 2022 (unpaid and ended) assert member.overdue_count == 1 end @@ -329,7 +329,7 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do actor ) - member = Ash.load!(member, :overdue_count) + member = Ash.load!(member, :overdue_count, actor: actor) assert member.overdue_count == 0 end @@ -337,7 +337,7 @@ defmodule Mv.Membership.MemberCycleCalculationsTest 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) + member = Ash.load!(member, :overdue_count, actor: actor) assert member.overdue_count == 0 end @@ -384,7 +384,7 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do actor ) - member = Ash.load!(member, :overdue_count) + member = Ash.load!(member, :overdue_count, actor: actor) # Should only count two_months_ago (unpaid and ended) assert member.overdue_count == 1 end @@ -424,7 +424,7 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do actor ) - member = Ash.load!(member, :overdue_count) + member = Ash.load!(member, :overdue_count, actor: actor) assert member.overdue_count == 3 end end @@ -470,7 +470,9 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do ) member = - Ash.load!(member, [:current_cycle_status, :last_cycle_status, :overdue_count]) + Ash.load!(member, [:current_cycle_status, :last_cycle_status, :overdue_count], + actor: actor + ) assert member.current_cycle_status == :unpaid assert member.last_cycle_status == :paid diff --git a/test/membership/member_type_change_integration_test.exs b/test/membership/member_type_change_integration_test.exs index 24d4355..cb289be 100644 --- a/test/membership/member_type_change_integration_test.exs +++ b/test/membership/member_type_change_integration_test.exs @@ -120,7 +120,7 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do |> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start) |> Ash.read_one(actor: actor) do {:ok, existing_cycle} when not is_nil(existing_cycle) -> - Ash.destroy!(existing_cycle) + Ash.destroy!(existing_cycle, actor: actor) _ -> :ok @@ -313,7 +313,7 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do |> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start) |> Ash.read_one(actor: actor) do {:ok, existing_cycle} when not is_nil(existing_cycle) -> - Ash.destroy!(existing_cycle) + Ash.destroy!(existing_cycle, actor: actor) _ -> :ok @@ -337,7 +337,7 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do |> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start) |> Ash.read_one(actor: actor) do {:ok, existing_cycle} when not is_nil(existing_cycle) -> - Ash.destroy!(existing_cycle) + Ash.destroy!(existing_cycle, actor: actor) _ -> :ok @@ -426,7 +426,7 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do Enum.each(existing_cycles, fn cycle -> if cycle.cycle_start != current_cycle_start do - Ash.destroy!(cycle) + Ash.destroy!(cycle, actor: actor) end end) diff --git a/test/membership_fees/membership_fee_cycle_test.exs b/test/membership_fees/membership_fee_cycle_test.exs index 4f78d1b..46d6216 100644 --- a/test/membership_fees/membership_fee_cycle_test.exs +++ b/test/membership_fees/membership_fee_cycle_test.exs @@ -190,27 +190,27 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do # unpaid -> paid cycle1 = create_cycle(member, fee_type, %{status: :unpaid}, actor) - assert {:ok, c1} = Ash.update(cycle1, %{}, action: :mark_as_paid) + assert {:ok, c1} = Ash.update(cycle1, %{}, actor: actor, action: :mark_as_paid) assert c1.status == :paid # paid -> suspended - assert {:ok, c2} = Ash.update(c1, %{}, action: :mark_as_suspended) + assert {:ok, c2} = Ash.update(c1, %{}, actor: actor, action: :mark_as_suspended) assert c2.status == :suspended # suspended -> unpaid - assert {:ok, c3} = Ash.update(c2, %{}, action: :mark_as_unpaid) + assert {:ok, c3} = Ash.update(c2, %{}, actor: actor, action: :mark_as_unpaid) assert c3.status == :unpaid # unpaid -> suspended - assert {:ok, c4} = Ash.update(c3, %{}, action: :mark_as_suspended) + assert {:ok, c4} = Ash.update(c3, %{}, actor: actor, action: :mark_as_suspended) assert c4.status == :suspended # suspended -> paid - assert {:ok, c5} = Ash.update(c4, %{}, action: :mark_as_paid) + assert {:ok, c5} = Ash.update(c4, %{}, actor: actor, action: :mark_as_paid) assert c5.status == :paid # paid -> unpaid - assert {:ok, c6} = Ash.update(c5, %{}, action: :mark_as_unpaid) + assert {:ok, c6} = Ash.update(c5, %{}, actor: actor, action: :mark_as_unpaid) assert c6.status == :unpaid end end diff --git a/test/mv/accounts/user_policies_test.exs b/test/mv/accounts/user_policies_test.exs index b7a0910..e04213a 100644 --- a/test/mv/accounts/user_policies_test.exs +++ b/test/mv/accounts/user_policies_test.exs @@ -343,29 +343,24 @@ defmodule Mv.Accounts.UserPoliciesTest do end describe "AshAuthentication bypass" do - test "register_with_password works with system actor" do - # Registration should work (AshAuthentication bypass in production) - # Note: When directly calling Ash actions in tests, the AshAuthentication bypass - # may not be active, so we use system_actor to test the functionality - system_actor = Mv.Helpers.SystemActor.get_system_actor() - - {:ok, user} = + test "register_with_password works without actor via AshAuthentication bypass" do + # Test that AshAuthentication bypass allows registration without actor + # This tests the actual bypass mechanism, not admin permissions + changeset = Accounts.User |> Ash.Changeset.for_create(:register_with_password, %{ email: "register#{System.unique_integer([:positive])}@example.com", password: "testpassword123" }) - |> Ash.create(actor: system_actor) + |> Ash.Changeset.set_context(%{private: %{ash_authentication?: true}}) + + {:ok, user} = Ash.create(changeset) assert user.email end - test "register_with_rauthy works with OIDC user_info" do - # OIDC registration should work (AshAuthentication bypass in production) - # Note: When directly calling Ash actions in tests, the AshAuthentication bypass - # may not be active, so we use system_actor to test the functionality - system_actor = Mv.Helpers.SystemActor.get_system_actor() - + test "register_with_rauthy works without actor via AshAuthentication bypass" do + # Test that AshAuthentication bypass allows OIDC registration without actor user_info = %{ "sub" => "oidc_sub_#{System.unique_integer([:positive])}", "email" => "oidc#{System.unique_integer([:positive])}@example.com" @@ -373,20 +368,24 @@ defmodule Mv.Accounts.UserPoliciesTest do oauth_tokens = %{access_token: "token", refresh_token: "refresh"} - {:ok, user} = + changeset = Accounts.User |> Ash.Changeset.for_create(:register_with_rauthy, %{ user_info: user_info, oauth_tokens: oauth_tokens }) - |> Ash.create(actor: system_actor) + |> Ash.Changeset.set_context(%{private: %{ash_authentication?: true}}) + + {:ok, user} = Ash.create(changeset) assert user.email assert user.oidc_id == user_info["sub"] end - test "sign_in_with_rauthy works with OIDC user_info" do - # First create a user with OIDC ID + test "sign_in_with_rauthy works without actor via AshAuthentication bypass" do + # First create a user with OIDC ID (using system_actor for setup) + system_actor = Mv.Helpers.SystemActor.get_system_actor() + user_info_create = %{ "sub" => "oidc_sub_#{System.unique_integer([:positive])}", "email" => "oidc#{System.unique_integer([:positive])}@example.com" @@ -394,8 +393,6 @@ 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, %{ @@ -404,14 +401,16 @@ defmodule Mv.Accounts.UserPoliciesTest do }) |> Ash.create(actor: system_actor) - # Now test sign_in_with_rauthy (should work via AshAuthentication bypass) - {:ok, signed_in_user} = + # Now test sign_in_with_rauthy without actor (should work via AshAuthentication bypass) + query = Accounts.User |> Ash.Query.for_read(:sign_in_with_rauthy, %{ user_info: user_info_create, oauth_tokens: oauth_tokens }) - |> Ash.read_one(actor: system_actor) + |> Ash.Query.set_context(%{private: %{ash_authentication?: true}}) + + {:ok, signed_in_user} = Ash.read_one(query) assert signed_in_user.id == user.id end diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 0f3d03b..0624c77 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -1323,7 +1323,8 @@ defmodule MvWeb.MemberLive.IndexTest do }) |> Ash.create(actor: system_actor) - member_without_value = member_without_value |> Ash.load!(:custom_field_values) + member_without_value = + member_without_value |> Ash.load!(:custom_field_values, actor: system_actor) members = [member_with_true, member_with_false, member_without_value] filters = %{to_string(boolean_field.id) => true} -- 2.47.2 From b545d2b9e13ff9d12f1768a04dd25bf2bcca9e72 Mon Sep 17 00:00:00 2001 From: Moritz Date: Sat, 24 Jan 2026 10:43:20 +0100 Subject: [PATCH 14/15] Remove NoActor module, improve Member validation, update docs --- CODE_GUIDELINES.md | 18 +++++ lib/membership/member.ex | 27 +++++-- lib/mv/authorization/checks/no_actor.ex | 70 ------------------- .../mv/authorization/checks/no_actor_test.exs | 52 -------------- 4 files changed, 40 insertions(+), 127 deletions(-) delete mode 100644 lib/mv/authorization/checks/no_actor.ex delete mode 100644 test/mv/authorization/checks/no_actor_test.exs diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 987be42..17b03d0 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -1699,6 +1699,24 @@ end **IMPORTANT:** All tests must explicitly provide an actor for Ash operations. The NoActor bypass has been removed to prevent masking authorization bugs. +**Exception: AshAuthentication Bypass Tests** + +Tests that verify the AshAuthentication bypass mechanism are a **conscious exception**. These tests must verify that registration/login works **without an actor** via the `AshAuthenticationInteraction` check. To enable this bypass in tests, set the context explicitly: + +```elixir +# ✅ GOOD - Testing AshAuthentication bypass (conscious exception) +changeset = + Accounts.User + |> Ash.Changeset.for_create(:register_with_password, %{...}) + |> Ash.Changeset.set_context(%{private: %{ash_authentication?: true}}) + +{:ok, user} = Ash.create(changeset) # No actor - tests bypass mechanism + +# ❌ BAD - Using system_actor masks the bypass test +system_actor = Mv.Helpers.SystemActor.get_system_actor() +Ash.create(changeset, actor: system_actor) # Tests admin permissions, not bypass! +``` + **Test Fixtures:** All test fixtures use `system_actor` for authorization: diff --git a/lib/membership/member.ex b/lib/membership/member.ex index f2f27c0..1a5d805 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -393,11 +393,28 @@ defmodule Mv.Membership.Member do user_id = user_arg[:id] current_member_id = changeset.data.id - # This is an integrity check, not a user authorization check - # Use authorize?: false to bypass policies for this internal validation query - # This ensures the validation always works regardless of actor availability - # (consistent with MembershipFeeType destroy validations) - case Ash.get(Mv.Accounts.User, user_id, authorize?: false) do + # Get actor from changeset context (may be nil) + actor = Map.get(changeset.context || %{}, :actor) + + # Check if authorization is disabled in the parent operation's context + # Access private context where authorize? flag is stored + authorize? = + case get_in(changeset.context, [:private, :authorize?]) do + false -> false + _ -> true + end + + # Use actor for authorization when available and authorize? is true + # Fall back to authorize?: false only for bootstrap/system operations + # This ensures normal operations respect authorization while system operations work + query_opts = + if actor && authorize? do + [actor: actor] + else + [authorize?: false] + end + + case Ash.get(Mv.Accounts.User, user_id, query_opts) do # User is free to be linked {:ok, %{member_id: nil}} -> :ok diff --git a/lib/mv/authorization/checks/no_actor.ex b/lib/mv/authorization/checks/no_actor.ex deleted file mode 100644 index 1c4946f..0000000 --- a/lib/mv/authorization/checks/no_actor.ex +++ /dev/null @@ -1,70 +0,0 @@ -defmodule Mv.Authorization.Checks.NoActor do - @moduledoc """ - Custom Ash Policy Check that allows actions when no actor is present. - - **IMPORTANT:** This check ONLY works in test environment for security reasons. - In production/dev, ALL operations without an actor are denied. - - ## Security Note - - This check uses compile-time environment detection to prevent accidental - security issues in production. In production, ALL operations (including :create - and :read) will be denied if no actor is present. - - For seeds and system operations in production, use an admin actor instead: - - admin_user = get_admin_user() - Ash.create!(resource, attrs, actor: admin_user) - - ## Usage in Policies - - policies do - # Allow system operations without actor (TEST ENVIRONMENT ONLY) - # In test: All operations allowed - # In production: ALL operations denied (fail-closed) - bypass action_type([:create, :read, :update, :destroy]) do - authorize_if NoActor - end - - # Check permissions when actor is present - policy action_type([:read, :create, :update, :destroy]) do - authorize_if HasPermission - end - end - - ## Behavior - - - In test environment: Returns `true` when actor is nil (allows all operations) - - In production/dev: Returns `false` when actor is nil (denies all operations - fail-closed) - - Returns `false` when actor is present (delegates to other policies) - """ - - use Ash.Policy.SimpleCheck - - # Compile-time check: Only allow no-actor bypass in test environment - # SECURITY: This must ONLY be true in test.exs, never in prod/dev - # Using compile_env instead of Mix.env() for release-safety - @allow_no_actor_bypass Application.compile_env(:mv, :allow_no_actor_bypass, false) - - @impl true - def describe(_opts) do - if @allow_no_actor_bypass do - "allows actions when no actor is present (test environment only)" - else - "denies all actions when no actor is present (production/dev - fail-closed)" - end - end - - @impl true - def match?(nil, _context, _opts) do - # Actor is nil - # SECURITY: Only allow if compile_env flag is set (test.exs only) - # No runtime Mix.env() check - fail-closed by default (false) - @allow_no_actor_bypass - end - - def match?(_actor, _context, _opts) do - # Actor is present - don't match (let other policies decide) - false - end -end diff --git a/test/mv/authorization/checks/no_actor_test.exs b/test/mv/authorization/checks/no_actor_test.exs deleted file mode 100644 index 35205a6..0000000 --- a/test/mv/authorization/checks/no_actor_test.exs +++ /dev/null @@ -1,52 +0,0 @@ -defmodule Mv.Authorization.Checks.NoActorTest do - @moduledoc """ - Tests for the NoActor Ash Policy Check. - - This check allows actions without an actor ONLY in test environment. - In production/dev, all operations without an actor are denied. - """ - use ExUnit.Case, async: true - - alias Mv.Authorization.Checks.NoActor - - describe "match?/3" do - test "returns true when actor is nil in test environment" do - # In test environment (config :allow_no_actor_bypass = true), NoActor allows operations - result = NoActor.match?(nil, %{}, []) - assert result == true - end - - test "returns false when actor is present" do - actor = %{id: "user-123"} - result = NoActor.match?(actor, %{}, []) - assert result == false - end - - test "uses compile-time config (not runtime Mix.env)" do - # The @allow_no_actor_bypass is set via Application.compile_env at compile time - # In test.exs: config :mv, :allow_no_actor_bypass, true - # In prod/dev: not set (defaults to false) - # This ensures the check is release-safe (no runtime Mix.env dependency) - result = NoActor.match?(nil, %{}, []) - - # In test environment (as compiled), should allow - assert result == true - - # Note: We cannot test "production mode" here because the flag is compile-time. - # Production safety is guaranteed by: - # 1. Config only set in test.exs - # 2. Default is false (fail-closed) - # 3. No runtime environment checks - end - end - - describe "describe/1" do - test "returns description based on compile-time config" do - description = NoActor.describe([]) - assert is_binary(description) - - # In test environment (compiled with :allow_no_actor_bypass = true) - assert description =~ "test environment" - end - end -end -- 2.47.2 From ef6cf1b2d4109305dfdcd3319f4710ad2854ef06 Mon Sep 17 00:00:00 2001 From: Moritz Date: Sat, 24 Jan 2026 10:43:50 +0100 Subject: [PATCH 15/15] Remove unused allow_no_actor_bypass config option --- config/test.exs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/config/test.exs b/config/test.exs index b47c764..33d608d 100644 --- a/config/test.exs +++ b/config/test.exs @@ -52,7 +52,3 @@ config :mv, :require_token_presence_for_authentication, false # Enable SQL Sandbox for async LiveView tests # This flag controls sync vs async behavior in CycleGenerator after_action hooks config :mv, :sql_sandbox, true - -# Allow operations without actor in test environment (NoActor check) -# SECURITY: This must ONLY be true in test.exs, never in prod/dev -config :mv, :allow_no_actor_bypass, true -- 2.47.2