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/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.
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
---
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..f2f27c0 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
@@ -402,21 +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
- # If no actor is present, this will fail in production (fail-closed)
- actor = Map.get(changeset.context || %{}, :actor)
-
- # 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
@@ -429,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 01ae625..498ff75 100644
--- a/lib/membership_fees/membership_fee_type.ex
+++ b/lib/membership_fees/membership_fee_type.ex
@@ -85,10 +85,11 @@ defmodule Mv.MembershipFees.MembershipFeeType do
if changeset.action_type == :destroy do
require Ash.Query
+ # 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!()
+ |> Ash.count!(authorize?: false)
if member_count > 0 do
{:error,
@@ -108,10 +109,11 @@ defmodule Mv.MembershipFees.MembershipFeeType do
if changeset.action_type == :destroy do
require Ash.Query
+ # 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!()
+ |> Ash.count!(authorize?: false)
if cycle_count > 0 do
{:error,
@@ -131,10 +133,11 @@ defmodule Mv.MembershipFees.MembershipFeeType do
if changeset.action_type == :destroy do
require Ash.Query
+ # 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!()
+ |> Ash.count!(authorize?: false)
if setting_count > 0 do
{:error,
diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex
index 73bfcd9..4ef355d 100644
--- a/lib/mv/constants.ex
+++ b/lib/mv/constants.ex
@@ -19,6 +19,12 @@ defmodule Mv.Constants do
@custom_field_prefix "custom_field_"
+ @boolean_filter_prefix "bf_"
+
+ @max_boolean_filters 50
+
+ @max_uuid_length 36
+
@email_validator_checks [:html_input, :pow]
def member_fields, do: @member_fields
@@ -33,6 +39,42 @@ defmodule Mv.Constants do
"""
def custom_field_prefix, do: @custom_field_prefix
+ @doc """
+ Returns the prefix used for boolean custom field filter URL parameters.
+
+ ## Examples
+
+ iex> Mv.Constants.boolean_filter_prefix()
+ "bf_"
+ """
+ def boolean_filter_prefix, do: @boolean_filter_prefix
+
+ @doc """
+ Returns the maximum number of boolean custom field filters allowed per request.
+
+ This limit prevents DoS attacks by restricting the number of filter parameters
+ that can be processed in a single request.
+
+ ## Examples
+
+ iex> Mv.Constants.max_boolean_filters()
+ 50
+ """
+ def max_boolean_filters, do: @max_boolean_filters
+
+ @doc """
+ Returns the maximum length of a UUID string (36 characters including hyphens).
+
+ UUIDs in standard format (e.g., "550e8400-e29b-41d4-a716-446655440000") are
+ exactly 36 characters long. This constant is used for input validation.
+
+ ## Examples
+
+ iex> Mv.Constants.max_uuid_length()
+ 36
+ """
+ def max_uuid_length, do: @max_uuid_length
+
@doc """
Returns the email validator checks used for EctoCommons.EmailValidator.
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)
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/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex
new file mode 100644
index 0000000..9286ace
--- /dev/null
+++ b/lib/mv_web/live/components/member_filter_component.ex
@@ -0,0 +1,444 @@
+defmodule MvWeb.Components.MemberFilterComponent do
+ @moduledoc """
+ Provides the MemberFilter Live-Component.
+
+ A DaisyUI dropdown filter for filtering members by payment status and boolean custom fields.
+ Uses radio inputs in a segmented control pattern (join + btn) for tri-state boolean filters.
+
+ ## Design Decisions
+
+ - Uses `div` panel instead of `ul.menu/li` structure to avoid DaisyUI menu styles
+ (padding, display, hover, font sizes) that would interfere with form controls.
+ - Filter controls are form elements (fieldset with legend, radio inputs), not menu items.
+ Uses semantic `
` and `` for proper accessibility and form structure.
+ - Dropdown stays open when clicking filter segments to allow multiple filter changes.
+ - Uses `phx-change` on form for radio inputs instead of individual `phx-click` events.
+
+ ## Props
+ - `:cycle_status_filter` - Current payment filter state: `nil` (all), `:paid`, or `:unpaid`
+ - `:boolean_custom_fields` - List of boolean custom fields to display
+ - `:boolean_filters` - Map of active boolean filters: `%{custom_field_id => true | false}`
+ - `:id` - Component ID (required)
+ - `:member_count` - Number of filtered members to display in badge (optional, default: 0)
+
+ ## Events
+ - Sends `{:payment_filter_changed, filter}` to parent when payment filter changes
+ - Sends `{:boolean_filter_changed, custom_field_id, filter_value}` to parent when boolean filter changes
+ """
+ use MvWeb, :live_component
+
+ @impl true
+ def mount(socket) do
+ {:ok, assign(socket, :open, false)}
+ end
+
+ @impl true
+ def update(assigns, socket) do
+ socket =
+ socket
+ |> assign(:id, assigns.id)
+ |> assign(:cycle_status_filter, assigns[:cycle_status_filter])
+ |> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || [])
+ |> assign(:boolean_filters, assigns[:boolean_filters] || %{})
+ |> assign(:member_count, assigns[:member_count] || 0)
+
+ {:ok, socket}
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
0) && "btn-active"
+ ]}
+ phx-click="toggle_dropdown"
+ phx-target={@myself}
+ aria-haspopup="true"
+ aria-expanded={to_string(@open)}
+ aria-label={gettext("Filter members")}
+ >
+ <.icon name="hero-funnel" class="h-5 w-5" />
+
+ {button_label(@cycle_status_filter, @boolean_custom_fields, @boolean_filters)}
+
+ 0}
+ class="badge badge-primary badge-sm"
+ >
+ {active_boolean_filters_count(@boolean_filters)}
+
+
+ {@member_count}
+
+
+
+
+
+
+ """
+ end
+
+ @impl true
+ def handle_event("toggle_dropdown", _params, socket) do
+ {:noreply, assign(socket, :open, !socket.assigns.open)}
+ end
+
+ @impl true
+ def handle_event("close_dropdown", _params, socket) do
+ {:noreply, assign(socket, :open, false)}
+ end
+
+ @impl true
+ def handle_event("update_filters", params, socket) do
+ # Parse payment filter
+ payment_filter =
+ case Map.get(params, "payment_filter") do
+ "paid" -> :paid
+ "unpaid" -> :unpaid
+ _ -> nil
+ end
+
+ # Parse boolean custom field filters (including nil values for "all")
+ custom_boolean_filters_parsed =
+ params
+ |> Map.get("custom_boolean", %{})
+ |> Enum.reduce(%{}, fn {custom_field_id_str, value_str}, acc ->
+ filter_value = parse_tri_state(value_str)
+ Map.put(acc, custom_field_id_str, filter_value)
+ end)
+
+ # Update payment filter if changed
+ if payment_filter != socket.assigns.cycle_status_filter do
+ send(self(), {:payment_filter_changed, payment_filter})
+ end
+
+ # Update boolean filters - send events for each changed filter
+ current_filters = socket.assigns.boolean_filters
+
+ # Process all custom field filters from form (including those set to "all"/nil)
+ # Radio buttons in a group always send a value, so all active filters are in the form
+ Enum.each(custom_boolean_filters_parsed, fn {custom_field_id_str, new_value} ->
+ current_value = Map.get(current_filters, custom_field_id_str)
+
+ # Only send event if value actually changed
+ if current_value != new_value do
+ send(self(), {:boolean_filter_changed, custom_field_id_str, new_value})
+ end
+ end)
+
+ # Don't close dropdown - allow multiple filter changes
+ {:noreply, socket}
+ end
+
+ @impl true
+ def handle_event("reset_filters", _params, socket) do
+ # Send single message to reset all filters at once (performance optimization)
+ # This avoids N×2 load_members() calls when resetting multiple filters
+ send(self(), {:reset_all_filters, nil, %{}})
+
+ # Close dropdown after reset
+ {:noreply, assign(socket, :open, false)}
+ end
+
+ # Parse tri-state filter value: "all" | "true" | "false" -> nil | true | false
+ defp parse_tri_state("true"), do: true
+ defp parse_tri_state("false"), do: false
+ defp parse_tri_state("all"), do: nil
+ defp parse_tri_state(_), do: nil
+
+ # Get display label for button
+ defp button_label(cycle_status_filter, boolean_custom_fields, boolean_filters) do
+ # If payment filter is active, show payment filter label
+ if cycle_status_filter do
+ payment_filter_label(cycle_status_filter)
+ else
+ # Otherwise show boolean filter labels
+ boolean_filter_label(boolean_custom_fields, boolean_filters)
+ end
+ end
+
+ # Get payment filter label
+ defp payment_filter_label(nil), do: gettext("All")
+ defp payment_filter_label(:paid), do: gettext("Paid")
+ defp payment_filter_label(:unpaid), do: gettext("Unpaid")
+
+ # Get boolean filter label (comma-separated list of active filter names)
+ defp boolean_filter_label(_boolean_custom_fields, boolean_filters)
+ when map_size(boolean_filters) == 0 do
+ gettext("All")
+ end
+
+ defp boolean_filter_label(boolean_custom_fields, boolean_filters) do
+ # Get names of active boolean filters
+ active_filter_names =
+ boolean_filters
+ |> Enum.map(fn {custom_field_id_str, _value} ->
+ Enum.find(boolean_custom_fields, fn cf -> to_string(cf.id) == custom_field_id_str end)
+ end)
+ |> Enum.filter(&(&1 != nil))
+ |> Enum.map(& &1.name)
+
+ # Join with comma and truncate if too long
+ label = Enum.join(active_filter_names, ", ")
+ truncate_label(label, 30)
+ end
+
+ # Truncate label if longer than max_length
+ defp truncate_label(label, max_length) when byte_size(label) <= max_length, do: label
+
+ defp truncate_label(label, max_length) do
+ String.slice(label, 0, max_length) <> "..."
+ end
+
+ # Count active boolean filters
+ defp active_boolean_filters_count(boolean_filters) do
+ map_size(boolean_filters)
+ end
+
+ # Get CSS classes for payment filter label based on current state
+ defp payment_filter_label_class(current_filter, expected_value) do
+ base_classes = "join-item btn btn-sm"
+ is_active = current_filter == expected_value
+
+ cond do
+ # All button (nil expected)
+ expected_value == nil ->
+ if is_active do
+ "#{base_classes} btn-active"
+ else
+ "#{base_classes} btn"
+ end
+
+ # Paid button
+ expected_value == :paid ->
+ if is_active do
+ "#{base_classes} btn-success btn-active"
+ else
+ "#{base_classes} btn"
+ end
+
+ # Unpaid button
+ expected_value == :unpaid ->
+ if is_active do
+ "#{base_classes} btn-error btn-active"
+ else
+ "#{base_classes} btn"
+ end
+
+ true ->
+ "#{base_classes} btn-outline"
+ end
+ end
+
+ # Get CSS classes for boolean filter label based on current state
+ defp boolean_filter_label_class(boolean_filters, custom_field_id, expected_value) do
+ base_classes = "join-item btn btn-sm"
+ current_value = Map.get(boolean_filters, to_string(custom_field_id))
+ is_active = current_value == expected_value
+
+ cond do
+ # All button (nil expected)
+ expected_value == nil ->
+ if is_active do
+ "#{base_classes} btn-active"
+ else
+ "#{base_classes} btn"
+ end
+
+ # True button
+ expected_value == true ->
+ if is_active do
+ "#{base_classes} btn-success btn-active"
+ else
+ "#{base_classes} btn"
+ end
+
+ # False button
+ expected_value == false ->
+ if is_active do
+ "#{base_classes} btn-error btn-active"
+ else
+ "#{base_classes} btn"
+ end
+
+ true ->
+ "#{base_classes} btn-outline"
+ end
+ end
+end
diff --git a/lib/mv_web/live/components/payment_filter_component.ex b/lib/mv_web/live/components/payment_filter_component.ex
deleted file mode 100644
index 9caaa1f..0000000
--- a/lib/mv_web/live/components/payment_filter_component.ex
+++ /dev/null
@@ -1,147 +0,0 @@
-defmodule MvWeb.Components.PaymentFilterComponent do
- @moduledoc """
- Provides the PaymentFilter Live-Component.
-
- A dropdown filter for filtering members by cycle payment status (paid/unpaid/all).
- Uses DaisyUI dropdown styling and sends filter changes to parent LiveView.
- Filter is based on cycle status (last or current cycle, depending on cycle view toggle).
-
- ## Props
- - `:cycle_status_filter` - Current filter state: `nil` (all), `:paid`, or `:unpaid`
- - `:id` - Component ID (required)
- - `:member_count` - Number of filtered members to display in badge (optional, default: 0)
-
- ## Events
- - Sends `{:payment_filter_changed, filter}` to parent when filter changes
- """
- use MvWeb, :live_component
-
- @impl true
- def mount(socket) do
- {:ok, assign(socket, :open, false)}
- end
-
- @impl true
- def update(assigns, socket) do
- socket =
- socket
- |> assign(:id, assigns.id)
- |> assign(:cycle_status_filter, assigns[:cycle_status_filter])
- |> assign(:member_count, assigns[:member_count] || 0)
-
- {:ok, socket}
- end
-
- @impl true
- def render(assigns) do
- ~H"""
-
-
- <.icon name="hero-funnel" class="h-5 w-5" />
- {filter_label(@cycle_status_filter)}
- {@member_count}
-
-
-
-
-
- <.icon name="hero-users" class="h-4 w-4" />
- {gettext("All")}
-
-
-
-
- <.icon name="hero-check-circle" class="h-4 w-4 text-success" />
- {gettext("Paid")}
-
-
-
-
- <.icon name="hero-x-circle" class="h-4 w-4 text-error" />
- {gettext("Unpaid")}
-
-
-
-
- """
- end
-
- @impl true
- def handle_event("toggle_dropdown", _params, socket) do
- {:noreply, assign(socket, :open, !socket.assigns.open)}
- end
-
- @impl true
- def handle_event("close_dropdown", _params, socket) do
- {:noreply, assign(socket, :open, false)}
- end
-
- @impl true
- def handle_event("select_filter", %{"filter" => filter_str}, socket) do
- filter = parse_filter(filter_str)
-
- # Close dropdown and notify parent
- socket = assign(socket, :open, false)
- send(self(), {:payment_filter_changed, filter})
-
- {:noreply, socket}
- end
-
- # Parse filter string to atom
- defp parse_filter("paid"), do: :paid
- defp parse_filter("unpaid"), do: :unpaid
- defp parse_filter(_), do: nil
-
- # Get display label for current filter
- defp filter_label(nil), do: gettext("All")
- defp filter_label(:paid), do: gettext("Paid")
- defp filter_label(:unpaid), do: gettext("Unpaid")
-end
diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex
index 2cf7392..50b0cfa 100644
--- a/lib/mv_web/live/member_live/index.ex
+++ b/lib/mv_web/live/member_live/index.ex
@@ -28,6 +28,7 @@ defmodule MvWeb.MemberLive.Index do
use MvWeb, :live_view
require Ash.Query
+ require Logger
import Ash.Expr
import MvWeb.LiveHelpers, only: [current_actor: 1]
@@ -41,6 +42,15 @@ defmodule MvWeb.MemberLive.Index do
# Prefix used in sort field names for custom fields (e.g., "custom_field_")
@custom_field_prefix Mv.Constants.custom_field_prefix()
+ # Prefix used for boolean custom field filter URL parameters (e.g., "bf_")
+ @boolean_filter_prefix Mv.Constants.boolean_filter_prefix()
+
+ # Maximum number of boolean custom field filters allowed per request (DoS protection)
+ @max_boolean_filters Mv.Constants.max_boolean_filters()
+
+ # Maximum length of UUID string (36 characters including hyphens)
+ @max_uuid_length Mv.Constants.max_uuid_length()
+
# Member fields that are loaded for the overview
# Uses constants from Mv.Constants to ensure consistency
# Note: :id is always included for member identification
@@ -72,6 +82,12 @@ defmodule MvWeb.MemberLive.Index do
|> Ash.Query.sort(name: :asc)
|> Ash.read!(actor: actor)
+ # Load boolean custom fields (filtered and sorted from all_custom_fields)
+ boolean_custom_fields =
+ all_custom_fields
+ |> Enum.filter(&(&1.value_type == :boolean))
+ |> Enum.sort_by(& &1.name, :asc)
+
# Load settings once to avoid N+1 queries
settings =
case Membership.get_settings() do
@@ -101,10 +117,12 @@ defmodule MvWeb.MemberLive.Index do
|> assign_new(:sort_field, fn -> :first_name end)
|> assign_new(:sort_order, fn -> :asc end)
|> assign(:cycle_status_filter, nil)
+ |> assign(:boolean_custom_field_filters, %{})
|> assign(:selected_members, MapSet.new())
|> assign(:settings, settings)
|> assign(:custom_fields_visible, custom_fields_visible)
|> assign(:all_custom_fields, all_custom_fields)
+ |> assign(:boolean_custom_fields, boolean_custom_fields)
|> assign(:all_available_fields, all_available_fields)
|> assign(:user_field_selection, initial_selection)
|> assign(
@@ -218,7 +236,8 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
- new_show_current
+ new_show_current,
+ socket.assigns.boolean_custom_field_filters
)
new_path = ~p"/members?#{query_params}"
@@ -332,7 +351,8 @@ defmodule MvWeb.MemberLive.Index do
existing_field_query,
existing_sort_query,
socket.assigns.cycle_status_filter,
- socket.assigns.show_current_cycle
+ socket.assigns.show_current_cycle,
+ socket.assigns.boolean_custom_field_filters
)
# Set the new path with params
@@ -361,7 +381,77 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
filter,
- socket.assigns.show_current_cycle
+ socket.assigns.show_current_cycle,
+ socket.assigns.boolean_custom_field_filters
+ )
+
+ new_path = ~p"/members?#{query_params}"
+
+ {:noreply,
+ push_patch(socket,
+ to: new_path,
+ replace: true
+ )}
+ end
+
+ @impl true
+ def handle_info({:boolean_filter_changed, custom_field_id_str, filter_value}, socket) do
+ # Update boolean filters map
+ updated_filters =
+ if filter_value == nil do
+ # Remove filter if nil (All option selected)
+ Map.delete(socket.assigns.boolean_custom_field_filters, custom_field_id_str)
+ else
+ # Add or update filter
+ Map.put(socket.assigns.boolean_custom_field_filters, custom_field_id_str, filter_value)
+ end
+
+ socket =
+ socket
+ |> assign(:boolean_custom_field_filters, updated_filters)
+ |> load_members()
+ |> update_selection_assigns()
+
+ # Build the URL with all params including new filter
+ query_params =
+ build_query_params(
+ socket.assigns.query,
+ socket.assigns.sort_field,
+ socket.assigns.sort_order,
+ socket.assigns.cycle_status_filter,
+ socket.assigns.show_current_cycle,
+ updated_filters
+ )
+
+ new_path = ~p"/members?#{query_params}"
+
+ {:noreply,
+ push_patch(socket,
+ to: new_path,
+ replace: true
+ )}
+ end
+
+ @impl true
+ def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do
+ # Reset all filters at once (performance optimization)
+ # This avoids N×2 load_members() calls when resetting multiple filters
+ socket =
+ socket
+ |> assign(:cycle_status_filter, cycle_status_filter)
+ |> assign(:boolean_custom_field_filters, boolean_filters)
+ |> load_members()
+ |> update_selection_assigns()
+
+ # Build the URL with all params including reset filters
+ query_params =
+ build_query_params(
+ socket.assigns.query,
+ socket.assigns.sort_field,
+ socket.assigns.sort_order,
+ cycle_status_filter,
+ socket.assigns.show_current_cycle,
+ boolean_filters
)
new_path = ~p"/members?#{query_params}"
@@ -448,6 +538,9 @@ defmodule MvWeb.MemberLive.Index do
"""
@impl true
def handle_params(params, _url, socket) do
+ # Build signature BEFORE updates to detect if anything actually changed
+ prev_sig = build_signature(socket)
+
# Parse field selection from URL
url_selection = FieldSelection.parse_from_url(params)
@@ -471,23 +564,68 @@ defmodule MvWeb.MemberLive.Index do
visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
+ # Apply all updates
socket =
socket
|> maybe_update_search(params)
|> maybe_update_sort(params)
|> maybe_update_cycle_status_filter(params)
+ |> maybe_update_boolean_filters(params)
|> maybe_update_show_current_cycle(params)
|> assign(:query, params["query"])
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
- |> load_members()
- |> prepare_dynamic_cols()
- |> update_selection_assigns()
+
+ # Build signature AFTER updates
+ next_sig = build_signature(socket)
+
+ # Only load members if signature changed (optimization: avoid duplicate loads)
+ # OR if members haven't been loaded yet (first handle_params call after mount)
+ socket =
+ if prev_sig == next_sig && Map.has_key?(socket.assigns, :members) do
+ # Nothing changed AND members already loaded, skip expensive load_members() call
+ socket
+ |> prepare_dynamic_cols()
+ |> update_selection_assigns()
+ else
+ # Signature changed OR members not loaded yet, reload members
+ socket
+ |> load_members()
+ |> prepare_dynamic_cols()
+ |> update_selection_assigns()
+ end
{:noreply, socket}
end
+ # Builds a signature tuple representing all filter/sort parameters that affect member loading.
+ #
+ # This signature is used to detect if member data needs to be reloaded when handle_params
+ # is called. If the signature hasn't changed, we can skip the expensive load_members() call.
+ #
+ # Returns a tuple containing all relevant parameters:
+ # - query: Search query string
+ # - sort_field: Field to sort by
+ # - sort_order: Sort direction (:asc or :desc)
+ # - cycle_status_filter: Payment filter (:paid, :unpaid, or nil)
+ # - show_current_cycle: Whether to show current cycle
+ # - boolean_custom_field_filters: Map of active boolean filters
+ # - user_field_selection: Map of user's field visibility selections
+ # - visible_custom_field_ids: List of visible custom field IDs (affects which custom fields are loaded)
+ defp build_signature(socket) do
+ {
+ socket.assigns.query,
+ socket.assigns.sort_field,
+ socket.assigns.sort_order,
+ socket.assigns.cycle_status_filter,
+ socket.assigns.show_current_cycle,
+ socket.assigns.boolean_custom_field_filters,
+ socket.assigns.user_field_selection,
+ socket.assigns[:visible_custom_field_ids] || []
+ }
+ end
+
# Prepares dynamic column definitions for custom fields that should be shown in the overview.
#
# Creates a list of column definitions, each containing:
@@ -586,7 +724,8 @@ defmodule MvWeb.MemberLive.Index do
field_str,
Atom.to_string(order),
socket.assigns.cycle_status_filter,
- socket.assigns.show_current_cycle
+ socket.assigns.show_current_cycle,
+ socket.assigns.boolean_custom_field_filters
)
new_path = ~p"/members?#{query_params}"
@@ -616,7 +755,8 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
- socket.assigns.show_current_cycle
+ socket.assigns.show_current_cycle,
+ socket.assigns.boolean_custom_field_filters
)
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
@@ -634,12 +774,14 @@ defmodule MvWeb.MemberLive.Index do
# Builds URL query parameters map including all filter/sort state.
# Converts cycle_status_filter atom to string for URL.
+ # Adds boolean custom field filters as bf_=true|false.
defp build_query_params(
query,
sort_field,
sort_order,
cycle_status_filter,
- show_current_cycle
+ show_current_cycle,
+ boolean_filters
) do
field_str =
if is_atom(sort_field) do
@@ -670,11 +812,19 @@ defmodule MvWeb.MemberLive.Index do
end
# Add show_current_cycle if true
- if show_current_cycle do
- Map.put(base_params, "show_current_cycle", "true")
- else
- base_params
- end
+ base_params =
+ if show_current_cycle do
+ Map.put(base_params, "show_current_cycle", "true")
+ else
+ base_params
+ end
+
+ # Add boolean custom field filters
+ Enum.reduce(boolean_filters, base_params, fn {custom_field_id, filter_value}, acc ->
+ param_key = "#{@boolean_filter_prefix}#{custom_field_id}"
+ param_value = if filter_value == true, do: "true", else: "false"
+ Map.put(acc, param_key, param_value)
+ end)
end
# Loads members from the database with custom field values and applies search/sort/payment filters.
@@ -704,9 +854,32 @@ defmodule MvWeb.MemberLive.Index do
|> Ash.Query.new()
|> Ash.Query.select(@overview_fields)
- # Load custom field values for visible custom fields (based on user selection)
+ # Load custom field values for visible custom fields AND active boolean filters
+ # This ensures boolean filters work even when the custom field is not visible in overview
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
- query = load_custom_field_values(query, visible_custom_field_ids)
+
+ # Get IDs of active boolean filters (whitelisted against boolean_custom_fields)
+ # Convert boolean_custom_fields list to map for efficient lookup (consistent with maybe_update_boolean_filters)
+ boolean_custom_fields_map =
+ socket.assigns.boolean_custom_fields
+ |> Map.new(fn cf -> {to_string(cf.id), cf} end)
+
+ active_boolean_filter_ids =
+ socket.assigns.boolean_custom_field_filters
+ |> Map.keys()
+ |> Enum.filter(fn id_str ->
+ # Validate UUID format and check against whitelist
+ String.length(id_str) <= @max_uuid_length &&
+ match?({:ok, _}, Ecto.UUID.cast(id_str)) &&
+ Map.has_key?(boolean_custom_fields_map, id_str)
+ end)
+
+ # Union of visible IDs and active filter IDs
+ ids_to_load =
+ (visible_custom_field_ids ++ active_boolean_filter_ids)
+ |> Enum.uniq()
+
+ query = load_custom_field_values(query, ids_to_load)
# Load membership fee cycles for status display
query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle)
@@ -726,7 +899,9 @@ defmodule MvWeb.MemberLive.Index do
# Errors in handle_params are handled by Phoenix LiveView
actor = current_actor(socket)
- members = Ash.read!(query, actor: actor)
+ {time_microseconds, members} = :timer.tc(fn -> Ash.read!(query, actor: actor) end)
+ time_milliseconds = time_microseconds / 1000
+ Logger.info("Ash.read! in load_members/1 took #{time_milliseconds} ms")
# Custom field values are already filtered at the database level in load_custom_field_values/2
# No need for in-memory filtering anymore
@@ -739,6 +914,14 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.show_current_cycle
)
+ # Apply boolean custom field filters if set
+ members =
+ apply_boolean_custom_field_filters(
+ members,
+ socket.assigns.boolean_custom_field_filters,
+ socket.assigns.all_custom_fields
+ )
+
# Sort in memory if needed (for custom fields)
members =
if sort_after_load do
@@ -1133,6 +1316,142 @@ defmodule MvWeb.MemberLive.Index do
defp determine_cycle_status_filter("unpaid"), do: :unpaid
defp determine_cycle_status_filter(_), do: nil
+ # Updates boolean custom field filters from URL parameters if present.
+ #
+ # Parses all URL parameters with prefix @boolean_filter_prefix and validates them:
+ # - Extracts custom field ID from parameter name (explicitly removes prefix)
+ # - Validates filter value using determine_boolean_filter/1
+ # - Whitelisting: Only custom field IDs that exist and have value_type: :boolean
+ # - Security: Limits to maximum @max_boolean_filters filters to prevent DoS attacks
+ # - Security: Validates UUID length (max @max_uuid_length characters)
+ #
+ # Returns socket with updated :boolean_custom_field_filters assign.
+ defp maybe_update_boolean_filters(socket, params) do
+ # Get all boolean custom fields for whitelisting (keyed by ID as string for consistency)
+ boolean_custom_fields =
+ socket.assigns.all_custom_fields
+ |> Enum.filter(&(&1.value_type == :boolean))
+ |> Map.new(fn cf -> {to_string(cf.id), cf} end)
+
+ # Parse all boolean filter parameters
+ # Security: Use reduce_while to abort early after @max_boolean_filters to prevent DoS attacks
+ # This protects CPU/Parsing costs, not just memory/state
+ # We count processed parameters (not just valid filters) to protect against parsing DoS
+ prefix_length = String.length(@boolean_filter_prefix)
+
+ {filters, total_processed} =
+ params
+ |> Enum.filter(fn {key, _value} -> String.starts_with?(key, @boolean_filter_prefix) end)
+ |> Enum.reduce_while({%{}, 0}, fn {key, value_str}, {acc, count} ->
+ if count >= @max_boolean_filters do
+ {:halt, {acc, count}}
+ else
+ new_acc =
+ process_boolean_filter_param(
+ key,
+ value_str,
+ prefix_length,
+ boolean_custom_fields,
+ acc
+ )
+
+ # Increment counter for each processed parameter (DoS protection)
+ # Note: We count processed params, not just valid filters, to protect parsing costs
+ {:cont, {new_acc, count + 1}}
+ end
+ end)
+
+ # Log warning if we hit the limit
+ if total_processed >= @max_boolean_filters do
+ Logger.warning(
+ "Boolean filter limit reached: processed #{total_processed} parameters, accepted #{map_size(filters)} valid filters (max: #{@max_boolean_filters})"
+ )
+ end
+
+ assign(socket, :boolean_custom_field_filters, filters)
+ end
+
+ # Processes a single boolean filter parameter from URL params.
+ #
+ # Validates the parameter and adds it to the accumulator if valid.
+ # Returns the accumulator unchanged if validation fails.
+ defp process_boolean_filter_param(
+ key,
+ value_str,
+ prefix_length,
+ boolean_custom_fields,
+ acc
+ ) do
+ # Extract custom field ID from parameter name (explicitly remove prefix)
+ # This is more secure than String.replace_prefix which only removes first occurrence
+ custom_field_id_str = String.slice(key, prefix_length, String.length(key) - prefix_length)
+
+ # Validate custom field ID length (UUIDs are max @max_uuid_length characters)
+ # This provides an additional security layer beyond UUID format validation
+ if String.length(custom_field_id_str) > @max_uuid_length do
+ acc
+ else
+ validate_and_add_boolean_filter(
+ custom_field_id_str,
+ value_str,
+ boolean_custom_fields,
+ acc
+ )
+ end
+ end
+
+ # Validates UUID format and custom field existence, then adds filter if valid.
+ defp validate_and_add_boolean_filter(
+ custom_field_id_str,
+ value_str,
+ boolean_custom_fields,
+ acc
+ ) do
+ case Ecto.UUID.cast(custom_field_id_str) do
+ {:ok, _custom_field_id} ->
+ add_boolean_filter_if_valid(
+ custom_field_id_str,
+ value_str,
+ boolean_custom_fields,
+ acc
+ )
+
+ :error ->
+ acc
+ end
+ end
+
+ # Adds boolean filter to accumulator if custom field exists and value is valid.
+ defp add_boolean_filter_if_valid(
+ custom_field_id_str,
+ value_str,
+ boolean_custom_fields,
+ acc
+ ) do
+ if Map.has_key?(boolean_custom_fields, custom_field_id_str) do
+ case determine_boolean_filter(value_str) do
+ nil -> acc
+ filter_value -> Map.put(acc, custom_field_id_str, filter_value)
+ end
+ else
+ acc
+ end
+ end
+
+ # Determines valid boolean filter value from URL parameter.
+ #
+ # SECURITY: This function whitelists allowed filter values. Only "true" and "false"
+ # are accepted - all other input (including malicious strings) falls back to nil.
+ # This ensures no raw user input is ever passed to filter functions.
+ #
+ # Returns:
+ # - `true` for "true" string
+ # - `false` for "false" string
+ # - `nil` for any other value
+ defp determine_boolean_filter("true"), do: true
+ defp determine_boolean_filter("false"), do: false
+ defp determine_boolean_filter(_), do: nil
+
# Updates show_current_cycle from URL parameters if present.
defp maybe_update_show_current_cycle(socket, %{"show_current_cycle" => "true"}) do
assign(socket, :show_current_cycle, true)
@@ -1166,7 +1485,166 @@ defmodule MvWeb.MemberLive.Index do
values when is_list(values) ->
Enum.find(values, fn cfv ->
cfv.custom_field_id == custom_field.id or
- (cfv.custom_field && cfv.custom_field.id == custom_field.id)
+ (match?(%{custom_field: %{id: _}}, cfv) && cfv.custom_field.id == custom_field.id)
+ end)
+
+ _ ->
+ nil
+ end
+ end
+
+ # Extracts the boolean value from a member's custom field value.
+ #
+ # Handles different value formats:
+ # - `%Ash.Union{value: value, type: :boolean}` - Extracts value from union
+ # - Map format with `"type"` and `"value"` keys - Extracts from map
+ # - Map format with `"_union_type"` and `"_union_value"` keys - Extracts from map
+ #
+ # Returns:
+ # - `true` if the custom field value is boolean true
+ # - `false` if the custom field value is boolean false
+ # - `nil` if no custom field value exists, value is nil, or value is not boolean
+ #
+ # Examples:
+ # get_boolean_custom_field_value(member, boolean_field) -> true
+ # get_boolean_custom_field_value(member, non_existent_field) -> nil
+ def get_boolean_custom_field_value(member, custom_field) do
+ case get_custom_field_value(member, custom_field) do
+ nil ->
+ nil
+
+ cfv ->
+ extract_boolean_value(cfv.value)
+ end
+ end
+
+ # Extracts boolean value from custom field value, handling different formats.
+ #
+ # Handles:
+ # - `%Ash.Union{value: value, type: :boolean}` - Union struct format
+ # - Map with `"type"` and `"value"` keys - JSONB map format
+ # - Map with `"_union_type"` and `"_union_value"` keys - Alternative map format
+ # - Direct boolean value - Primitive boolean
+ #
+ # Returns `true`, `false`, or `nil`.
+ defp extract_boolean_value(%Ash.Union{value: value, type: :boolean}) do
+ extract_boolean_value(value)
+ end
+
+ defp extract_boolean_value(value) when is_map(value) do
+ # Handle map format from JSONB
+ type = Map.get(value, "type") || Map.get(value, "_union_type")
+ val = Map.get(value, "value") || Map.get(value, "_union_value")
+
+ if type == "boolean" or type == :boolean do
+ extract_boolean_value(val)
+ else
+ nil
+ end
+ end
+
+ defp extract_boolean_value(value) when is_boolean(value), do: value
+ defp extract_boolean_value(nil), do: nil
+ defp extract_boolean_value(_), do: nil
+
+ # Applies boolean custom field filters to a list of members.
+ #
+ # Filters members based on boolean custom field values. Only members that match
+ # ALL active filters (AND logic) are returned.
+ #
+ # Parameters:
+ # - `members` - List of Member resources with loaded custom_field_values
+ # - `filters` - Map of `%{custom_field_id_string => true | false}`
+ # - `all_custom_fields` - List of all CustomField resources (for validation)
+ #
+ # Returns:
+ # - Filtered list of members that match all active filters
+ # - All members if filters map is empty
+ # - Filters with non-existent custom field IDs are ignored
+ #
+ # Examples:
+ # apply_boolean_custom_field_filters(members, %{"uuid-123" => true}, all_custom_fields) -> [member1, ...]
+ # apply_boolean_custom_field_filters(members, %{}, all_custom_fields) -> members
+ def apply_boolean_custom_field_filters(members, filters, _all_custom_fields)
+ when map_size(filters) == 0 do
+ members
+ end
+
+ def apply_boolean_custom_field_filters(members, filters, all_custom_fields) do
+ # Build a map of valid boolean custom field IDs (as strings) for quick lookup
+ valid_custom_field_ids =
+ all_custom_fields
+ |> Enum.filter(&(&1.value_type == :boolean))
+ |> MapSet.new(fn cf -> to_string(cf.id) end)
+
+ # Filter out invalid custom field IDs from filters
+ valid_filters =
+ Enum.filter(filters, fn {custom_field_id_str, _value} ->
+ MapSet.member?(valid_custom_field_ids, custom_field_id_str)
+ end)
+ |> Enum.into(%{})
+
+ # If no valid filters remain, return all members
+ if map_size(valid_filters) == 0 do
+ members
+ else
+ Enum.filter(members, fn member ->
+ matches_all_filters?(member, valid_filters)
+ end)
+ end
+ end
+
+ # Checks if a member matches all active boolean filters.
+ #
+ # A member matches a filter if:
+ # - The filter value is `true` and the member's custom field value is `true`
+ # - The filter value is `false` and the member's custom field value is `false`
+ #
+ # Members without a custom field value or with `nil` value do not match any filter.
+ #
+ # Returns `true` if all filters match, `false` otherwise.
+ defp matches_all_filters?(member, filters) do
+ Enum.all?(filters, fn {custom_field_id_str, filter_value} ->
+ matches_filter?(member, custom_field_id_str, filter_value)
+ end)
+ end
+
+ # Checks if a member matches a specific boolean filter.
+ #
+ # Finds the custom field value by ID and checks if the member's boolean value
+ # matches the filter value.
+ #
+ # Returns:
+ # - `true` if the member's boolean value matches the filter value
+ # - `false` if no custom field value exists (member is filtered out)
+ # - `false` if value is nil or values don't match
+ defp matches_filter?(member, custom_field_id_str, filter_value) do
+ case find_custom_field_value_by_id(member, custom_field_id_str) do
+ nil ->
+ false
+
+ cfv ->
+ boolean_value = extract_boolean_value(cfv.value)
+ boolean_value == filter_value
+ end
+ end
+
+ # Finds a custom field value by custom field ID string.
+ #
+ # Searches through the member's custom_field_values to find one matching
+ # the given custom field ID.
+ #
+ # Returns the CustomFieldValue or nil.
+ defp find_custom_field_value_by_id(member, custom_field_id_str) do
+ case member.custom_field_values do
+ nil ->
+ nil
+
+ values when is_list(values) ->
+ Enum.find(values, fn cfv ->
+ to_string(cfv.custom_field_id) == custom_field_id_str or
+ (match?(%{custom_field: %{id: _}}, cfv) &&
+ to_string(cfv.custom_field.id) == custom_field_id_str)
end)
_ ->
@@ -1221,8 +1699,11 @@ defmodule MvWeb.MemberLive.Index do
#
# Note: Mailto URLs have length limits that vary by email client.
# For large selections, consider using export functionality instead.
+ #
+ # Handles case where members haven't been loaded yet (e.g., when signature didn't change in handle_params).
defp update_selection_assigns(socket) do
- members = socket.assigns.members
+ # Handle case where members haven't been loaded yet (e.g., when signature didn't change)
+ members = socket.assigns[:members] || []
selected_members = socket.assigns.selected_members
selected_count =
diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex
index b2af205..394db2c 100644
--- a/lib/mv_web/live/member_live/index.html.heex
+++ b/lib/mv_web/live/member_live/index.html.heex
@@ -37,9 +37,11 @@
placeholder={gettext("Search...")}
/>
<.live_component
- module={MvWeb.Components.PaymentFilterComponent}
- id="payment-filter"
+ module={MvWeb.Components.MemberFilterComponent}
+ id="member-filter"
cycle_status_filter={@cycle_status_filter}
+ boolean_custom_fields={@boolean_custom_fields}
+ boolean_filters={@boolean_custom_field_filters}
member_count={length(@members)}
/>
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..feb7180 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])
+
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])
+
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..daafa1b 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])
+
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])
+
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])
+
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])
+
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])
+
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])
+
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])
+
+ {:ok, member_with_user} =
+ 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
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])
- {: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..b7a0910 100644
--- a/test/mv/accounts/user_policies_test.exs
+++ b/test/mv/accounts/user_policies_test.exs
@@ -14,15 +14,23 @@ 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
+ 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
@@ -30,9 +38,9 @@ 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)
+ role = create_role_with_permission_set(permission_set_name, actor)
# Create user
{:ok, user} =
@@ -41,39 +49,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 +149,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 +217,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 +285,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
@@ -333,21 +343,29 @@ 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 to test the functionality
+ 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)
+ # 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()
+
user_info = %{
"sub" => "oidc_sub_#{System.unique_integer([:positive])}",
"email" => "oidc#{System.unique_integer([:positive])}@example.com"
@@ -361,7 +379,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 +394,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 +411,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 +423,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..9fba86e 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,10 +25,10 @@ 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)
+ {: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)
@@ -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..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,7 +36,8 @@ 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)
+ {:ok, results} =
+ Ash.read(query, domain: Mv.Membership, authorize?: false)
# 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..0bbe1c1 100644
--- a/test/mv/membership/member_policies_test.exs
+++ b/test/mv/membership/member_policies_test.exs
@@ -16,15 +16,23 @@ 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(%{
- 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
@@ -32,9 +40,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 +51,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 +104,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 +120,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 +175,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 +198,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 +209,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 +234,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 +278,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 +336,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 +383,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 +395,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 +421,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 +440,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/components/member_filter_component_test.exs b/test/mv_web/components/member_filter_component_test.exs
new file mode 100644
index 0000000..a3a3846
--- /dev/null
+++ b/test/mv_web/components/member_filter_component_test.exs
@@ -0,0 +1,300 @@
+defmodule MvWeb.Components.MemberFilterComponentTest do
+ @moduledoc """
+ Unit tests for the MemberFilterComponent.
+
+ Tests cover:
+ - Rendering Payment Filter and Boolean Custom Fields
+ - Boolean filter selection and event emission
+ - Button label and badge logic
+ - Filtering to show only boolean custom fields
+ """
+ # async: false to prevent PostgreSQL deadlocks when running LiveView tests against DB
+ use MvWeb.ConnCase, async: false
+
+ import Phoenix.LiveViewTest
+ use Gettext, backend: MvWeb.Gettext
+
+ alias Mv.Membership.CustomField
+
+ # Helper to create a boolean custom field
+ defp create_boolean_custom_field(attrs \\ %{}) do
+ default_attrs = %{
+ name: "test_boolean_#{System.unique_integer([:positive])}",
+ value_type: :boolean
+ }
+
+ attrs = Map.merge(default_attrs, attrs)
+
+ CustomField
+ |> Ash.Changeset.for_create(:create, attrs)
+ |> Ash.create!()
+ end
+
+ # Helper to create a non-boolean custom field
+ defp create_string_custom_field(attrs \\ %{}) do
+ default_attrs = %{
+ name: "test_string_#{System.unique_integer([:positive])}",
+ value_type: :string
+ }
+
+ attrs = Map.merge(default_attrs, attrs)
+
+ CustomField
+ |> Ash.Changeset.for_create(:create, attrs)
+ |> Ash.create!()
+ end
+
+ describe "rendering" do
+ test "renders boolean custom fields when present", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ boolean_field = create_boolean_custom_field(%{name: "Active Member"})
+
+ {:ok, view, _html} = live(conn, "/members")
+
+ # Should show the boolean custom field name in the dropdown
+ view
+ |> element("#member-filter button[aria-haspopup='true']")
+ |> render_click()
+
+ html = render(view)
+ assert html =~ boolean_field.name
+ end
+
+ test "renders payment and custom fields groups when boolean fields exist", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ _boolean_field = create_boolean_custom_field()
+
+ {:ok, view, _html} = live(conn, "/members")
+
+ # Open dropdown
+ view
+ |> element("#member-filter button[aria-haspopup='true']")
+ |> render_click()
+
+ html = render(view)
+ # Should have both "Payments" and "Custom Fields" group labels
+ assert html =~ gettext("Payments") || html =~ "Payment"
+ assert html =~ gettext("Custom Fields")
+ end
+
+ test "renders only payment filter when no boolean custom fields exist", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ # Create a non-boolean field to ensure it's not shown
+ _string_field = create_string_custom_field()
+
+ {:ok, view, _html} = live(conn, "/members")
+
+ # Component should exist with correct ID
+ assert has_element?(view, "#member-filter")
+
+ # Open dropdown
+ view
+ |> element("#member-filter button[aria-haspopup='true']")
+ |> render_click()
+
+ html = render(view)
+
+ # Should show payment filter options (check both English and translated)
+ assert html =~ "All" || html =~ gettext("All")
+ assert html =~ "Paid" || html =~ gettext("Paid")
+ assert html =~ "Unpaid" || html =~ gettext("Unpaid")
+
+ # Should not show any boolean field names (since none exist)
+ # We can't easily check this without knowing field names, but the structure should be correct
+ end
+ end
+
+ describe "boolean filter selection" do
+ test "selecting boolean filter sends correct event", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ boolean_field = create_boolean_custom_field(%{name: "Newsletter"})
+
+ {:ok, view, _html} = live(conn, "/members")
+
+ # Open dropdown
+ view
+ |> element("#member-filter button[aria-haspopup='true']")
+ |> render_click()
+
+ # Select "True" option for the boolean field using radio input
+ # Radio inputs trigger phx-change on the form, so we use render_change on the form
+ view
+ |> form("#member-filter form", %{
+ "custom_boolean" => %{to_string(boolean_field.id) => "true"}
+ })
+ |> render_change()
+
+ # The event should be sent to the parent LiveView
+ # We verify this by checking that the URL is updated
+ assert_patch(view)
+ end
+
+ test "payment filter still works after component extension", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ _boolean_field = create_boolean_custom_field()
+
+ {:ok, view, _html} = live(conn, "/members")
+
+ # Open dropdown
+ view
+ |> element("#member-filter button[aria-haspopup='true']")
+ |> render_click()
+
+ # Select "Paid" option using radio input
+ # Radio inputs trigger phx-change on the form, so we use render_change on the form
+ view
+ |> form("#member-filter form", %{"payment_filter" => "paid"})
+ |> render_change()
+
+ # URL should be updated with cycle_status_filter=paid
+ path = assert_patch(view)
+ assert path =~ "cycle_status_filter=paid"
+ end
+ end
+
+ describe "button label" do
+ test "shows active boolean filter names in button label", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ boolean_field1 = create_boolean_custom_field(%{name: "Active Member"})
+ boolean_field2 = create_boolean_custom_field(%{name: "Newsletter"})
+
+ # Set filters via URL
+ {:ok, view, _html} =
+ live(
+ conn,
+ "/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false"
+ )
+
+ # Component should exist
+ assert has_element?(view, "#member-filter")
+
+ # Button label should contain the custom field names
+ # The exact format depends on implementation, but should show active filters
+ button_html =
+ view
+ |> element("#member-filter button[aria-haspopup='true']")
+ |> render()
+
+ assert button_html =~ boolean_field1.name || button_html =~ boolean_field2.name
+ end
+
+ test "truncates long custom field names in button label", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ # Create field with very long name (>30 characters)
+ long_name = String.duplicate("A", 50)
+ boolean_field = create_boolean_custom_field(%{name: long_name})
+
+ # Set filter via URL
+ {:ok, view, _html} =
+ live(conn, "/members?bf_#{boolean_field.id}=true")
+
+ # Component should exist
+ assert has_element?(view, "#member-filter")
+
+ # Get button label text
+ button_html =
+ view
+ |> element("#member-filter button[aria-haspopup='true']")
+ |> render()
+
+ # Button label should be truncated - full name should NOT appear in button
+ # (it may appear in dropdown when opened, but not in the button label itself)
+ # Check that button doesn't contain the full 50-character name
+ refute button_html =~ long_name
+
+ # Button should still contain some text (truncated version or indicator)
+ assert String.length(button_html) > 0
+ end
+ end
+
+ describe "badge" do
+ test "shows total count of active boolean filters in badge", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ boolean_field1 = create_boolean_custom_field(%{name: "Field1"})
+ boolean_field2 = create_boolean_custom_field(%{name: "Field2"})
+
+ # Set two filters via URL
+ {:ok, view, _html} =
+ live(
+ conn,
+ "/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false"
+ )
+
+ # Component should exist
+ assert has_element?(view, "#member-filter")
+
+ # Badge should be visible when boolean filters are active
+ assert has_element?(view, "#member-filter .badge")
+
+ # Badge should show count of active boolean filters (2 in this case)
+ badge_html =
+ view
+ |> element("#member-filter .badge")
+ |> render()
+
+ assert badge_html =~ "2"
+ end
+ end
+
+ describe "filtering" do
+ test "only boolean custom fields are displayed, not string or integer fields", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ boolean_field = create_boolean_custom_field(%{name: "Boolean Field"})
+ _string_field = create_string_custom_field(%{name: "String Field"})
+
+ {:ok, view, _html} = live(conn, "/members")
+
+ # Open dropdown
+ view
+ |> element("#member-filter button[aria-haspopup='true']")
+ |> render_click()
+
+ # Should show boolean field in the dropdown panel
+ # Extract only the dropdown panel HTML to check
+ dropdown_html =
+ view
+ |> element("#member-filter div[role='dialog']")
+ |> render()
+
+ # Should show boolean field in dropdown
+ assert dropdown_html =~ boolean_field.name
+
+ # Should not show string field name in the filter dropdown
+ # (String fields should not appear in boolean filter section)
+ refute dropdown_html =~ "String Field"
+ end
+
+ test "dropdown shows scrollbar when many boolean custom fields exist", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+
+ # Create 15 boolean custom fields (more than typical, should trigger scrollbar)
+ boolean_fields =
+ Enum.map(1..15, fn i ->
+ create_boolean_custom_field(%{name: "Field #{i}"})
+ end)
+
+ {:ok, view, _html} = live(conn, "/members")
+
+ # Open dropdown
+ view
+ |> element("#member-filter button[aria-haspopup='true']")
+ |> render_click()
+
+ # Extract dropdown panel HTML
+ dropdown_html =
+ view
+ |> element("#member-filter div[role='dialog']")
+ |> render()
+
+ # Should have scrollbar classes: max-h-60 overflow-y-auto pr-2
+ # Check for the scrollable container (the div with max-h-60 and overflow-y-auto)
+ assert dropdown_html =~ "max-h-60"
+ assert dropdown_html =~ "overflow-y-auto"
+
+ # Verify all fields are present in the dropdown
+ Enum.each(boolean_fields, fn field ->
+ assert dropdown_html =~ field.name
+ end)
+ end
+ end
+end
diff --git a/test/mv_web/components/payment_filter_component_test.exs b/test/mv_web/components/payment_filter_component_test.exs
deleted file mode 100644
index 7987efa..0000000
--- a/test/mv_web/components/payment_filter_component_test.exs
+++ /dev/null
@@ -1,183 +0,0 @@
-defmodule MvWeb.Components.PaymentFilterComponentTest do
- @moduledoc """
- Unit tests for the PaymentFilterComponent.
-
- Tests cover:
- - Rendering in all 3 filter states (nil, :paid, :unpaid)
- - Event emission when selecting options
- - ARIA attributes for accessibility
- - Dropdown open/close behavior
- """
- # async: false to prevent PostgreSQL deadlocks when running LiveView tests against DB
- use MvWeb.ConnCase, async: false
-
- import Phoenix.LiveViewTest
-
- describe "rendering" do
- test "renders with no filter active (nil)", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
-
- # Should show "All" text and no badge
- assert has_element?(view, "#payment-filter")
- refute has_element?(view, "#payment-filter .badge")
- end
-
- test "renders with paid filter active", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid")
-
- # Should show badge when filter is active
- assert has_element?(view, "#payment-filter .badge")
- end
-
- test "renders with unpaid filter active", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?cycle_status_filter=unpaid")
-
- # Should show badge when filter is active
- assert has_element?(view, "#payment-filter .badge")
- end
- end
-
- describe "dropdown behavior" do
- test "dropdown opens on button click", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
-
- # Initially dropdown is closed
- refute has_element?(view, "#payment-filter ul[role='menu']")
-
- # Click to open
- view
- |> element("#payment-filter button[aria-haspopup='true']")
- |> render_click()
-
- # Dropdown should be visible
- assert has_element?(view, "#payment-filter ul[role='menu']")
- end
-
- test "dropdown closes after selecting an option", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
-
- # Open dropdown
- view
- |> element("#payment-filter button[aria-haspopup='true']")
- |> render_click()
-
- assert has_element?(view, "#payment-filter ul[role='menu']")
-
- # Select an option - this should close the dropdown
- view
- |> element("#payment-filter button[phx-value-filter='paid']")
- |> render_click()
-
- # After selection, dropdown should be closed
- # Note: The dropdown closes via assign, which is reflected in the next render
- refute has_element?(view, "#payment-filter ul[role='menu']")
- end
- end
-
- describe "filter selection" do
- test "selecting 'All' clears the filter and updates URL", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid")
-
- # Open dropdown
- view
- |> element("#payment-filter button[aria-haspopup='true']")
- |> render_click()
-
- # Select "All" option
- view
- |> element("#payment-filter button[phx-value-filter='']")
- |> render_click()
-
- # URL should not contain cycle_status_filter param - wait for patch
- assert_patch(view)
- end
-
- test "selecting 'Paid' sets the filter and updates URL", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
-
- # Open dropdown
- view
- |> element("#payment-filter button[aria-haspopup='true']")
- |> render_click()
-
- # Select "Paid" option
- view
- |> element("#payment-filter button[phx-value-filter='paid']")
- |> render_click()
-
- # Wait for patch and check URL contains cycle_status_filter=paid
- path = assert_patch(view)
- assert path =~ "cycle_status_filter=paid"
- end
-
- test "selecting 'Unpaid' sets the filter and updates URL", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
-
- # Open dropdown
- view
- |> element("#payment-filter button[aria-haspopup='true']")
- |> render_click()
-
- # Select "Unpaid" option
- view
- |> element("#payment-filter button[phx-value-filter='unpaid']")
- |> render_click()
-
- # Wait for patch and check URL contains cycle_status_filter=unpaid
- path = assert_patch(view)
- assert path =~ "cycle_status_filter=unpaid"
- end
- end
-
- describe "accessibility" do
- test "has correct ARIA attributes", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, html} = live(conn, "/members")
-
- # Main button should have aria-haspopup and aria-expanded
- assert html =~ ~s(aria-haspopup="true")
- assert html =~ ~s(aria-expanded="false")
- assert html =~ ~s(aria-label=)
-
- # Open dropdown
- view
- |> element("#payment-filter button[aria-haspopup='true']")
- |> render_click()
-
- html = render(view)
-
- # Check aria-expanded is now true
- assert html =~ ~s(aria-expanded="true")
-
- # Menu should have role="menu"
- assert html =~ ~s(role="menu")
-
- # Options should have role="menuitemradio"
- assert html =~ ~s(role="menuitemradio")
- end
-
- test "has aria-checked on selected option", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid")
-
- # Open dropdown
- view
- |> element("#payment-filter button[aria-haspopup='true']")
- |> render_click()
-
- html = render(view)
-
- # "Paid" option should have aria-checked="true"
- # Check both possible orderings of attributes
- assert html =~ "aria-checked=\"true\"" and html =~ "phx-value-filter=\"paid\""
- end
- end
-end
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/membership_fee_type_live/index_test.exs b/test/mv_web/live/membership_fee_type_live/index_test.exs
index 9c5ad55..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,7 +15,8 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
# No custom setup needed
# Helper to create a membership fee type
- defp create_fee_type(attrs) do
+ # 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"),
@@ -26,7 +27,7 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
- |> Ash.create!()
+ |> Ash.create!(actor: admin_user)
end
# Helper to create a member
@@ -48,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")
@@ -65,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 _ ->
@@ -88,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")
@@ -104,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")
@@ -113,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")
@@ -124,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/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..4931058 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,17 +69,17 @@ 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)
+ 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)
@@ -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..d3db337 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,17 +60,17 @@ 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)
+ 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)
@@ -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..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,7 +12,8 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
require Ash.Query
# Helper to create a membership fee type
- defp create_fee_type(attrs) do
+ # 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"),
@@ -23,11 +24,12 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
- |> Ash.create!()
+ |> Ash.create!(actor: admin_user)
end
# Helper to create a member
- defp create_member(attrs) do
+ # Uses admin_user to test permissions (UI-/Permissions-nah)
+ defp create_member(attrs, admin_user) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
@@ -38,7 +40,7 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
Member
|> Ash.Changeset.for_create(:create_member, attrs)
- |> Ash.create!()
+ |> Ash.create!(actor: admin_user)
end
describe "membership fee type dropdown" do
@@ -50,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")
@@ -60,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")
@@ -73,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")
@@ -88,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")
@@ -105,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")
@@ -122,18 +127,18 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|> form("#member-form", form_data)
|> render_submit()
- # Verify member was created with fee type
+ # 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!()
+ |> 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)
{:ok, settings} = Mv.Membership.get_settings()
@@ -141,7 +146,7 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
default_membership_fee_type_id: fee_type.id
})
- |> Ash.update!()
+ |> Ash.update!(actor: admin_user)
{:ok, view, _html} = live(conn, "/members/new")
@@ -156,7 +161,7 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
conn: conn,
current_user: admin_user
} do
- # Create custom field
+ # Create custom field - use admin_user to test permissions
custom_field =
Mv.Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
@@ -164,11 +169,11 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
value_type: :string,
required: false
})
- |> Ash.create!()
+ |> 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 =
@@ -203,7 +208,7 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
end
test "union/typed values roundtrip correctly", %{conn: conn, current_user: admin_user} do
- # Create date custom field
+ # Create date custom field - use admin_user to test permissions
custom_field =
Mv.Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
@@ -211,9 +216,9 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
value_type: :date,
required: false
})
- |> Ash.create!()
+ |> 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 =
@@ -250,7 +255,7 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
end
test "removing custom field values works correctly", %{conn: conn, current_user: admin_user} do
- # Create custom field
+ # Create custom field - use admin_user to test permissions
custom_field =
Mv.Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
@@ -258,9 +263,9 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
value_type: :string,
required: false
})
- |> Ash.create!()
+ |> 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 =
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 acca9bf..0f3d03b 100644
--- a/test/mv_web/member_live/index_test.exs
+++ b/test/mv_web/member_live/index_test.exs
@@ -3,6 +3,49 @@ defmodule MvWeb.MemberLive.IndexTest do
import Phoenix.LiveViewTest
require Ash.Query
+ alias Mv.MembershipFees.MembershipFeeType
+ alias Mv.MembershipFees.MembershipFeeCycle
+
+ # Helper to create a membership fee type (shared across all tests)
+ 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 cycle (shared across all tests)
+ 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 "shows translated title in German", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = Plug.Test.init_test_session(conn, locale: "de")
@@ -223,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")
@@ -251,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
@@ -351,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
@@ -391,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")
@@ -457,26 +522,8 @@ 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) 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!()
- end
-
- # Helper to create a member
- defp create_member(attrs) do
+ # Helper to create a member (only used in this describe block)
+ defp create_member(attrs, actor) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
@@ -487,57 +534,49 @@ defmodule MvWeb.MemberLive.IndexTest do
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, attrs)
- |> Ash.create!()
- end
-
- # Helper to create a cycle
- defp create_cycle(member, fee_type, attrs) do
- # Delete any auto-generated cycles first to avoid conflicts
- existing_cycles =
- MembershipFeeCycle
- |> Ash.Query.filter(member_id == ^member.id)
- |> Ash.read!()
-
- Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle) 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!()
+ |> 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 +585,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 +632,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 +679,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")
@@ -656,4 +746,1091 @@ defmodule MvWeb.MemberLive.IndexTest do
assert path =~ "show_current_cycle=true"
end
end
+
+ describe "boolean custom field filters" do
+ alias Mv.Membership.CustomField
+
+ # Helper to create a boolean custom field
+ defp create_boolean_custom_field(attrs \\ %{}) do
+ default_attrs = %{
+ name: "test_boolean_#{System.unique_integer([:positive])}",
+ value_type: :boolean
+ }
+
+ attrs = Map.merge(default_attrs, attrs)
+
+ CustomField
+ |> Ash.Changeset.for_create(:create, attrs)
+ |> Ash.create!()
+ end
+
+ # Helper to create a non-boolean custom field
+ defp create_string_custom_field(attrs \\ %{}) do
+ default_attrs = %{
+ name: "test_string_#{System.unique_integer([:positive])}",
+ value_type: :string
+ }
+
+ attrs = Map.merge(default_attrs, attrs)
+
+ CustomField
+ |> Ash.Changeset.for_create(:create, attrs)
+ |> Ash.create!()
+ end
+
+ test "mount initializes boolean_custom_field_filters as empty map", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members")
+
+ state = :sys.get_state(view.pid)
+ assert state.socket.assigns.boolean_custom_field_filters == %{}
+ end
+
+ test "mount initializes boolean_custom_fields as empty list when no boolean fields exist", %{
+ conn: conn
+ } do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members")
+
+ state = :sys.get_state(view.pid)
+ assert state.socket.assigns.boolean_custom_fields == []
+ end
+
+ test "mount loads and filters boolean custom fields correctly", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+
+ # Create boolean and non-boolean custom fields
+ boolean_field1 = create_boolean_custom_field(%{name: "Active Member"})
+ boolean_field2 = create_boolean_custom_field(%{name: "Newsletter Subscription"})
+ _string_field = create_string_custom_field(%{name: "Phone Number"})
+
+ {:ok, view, _html} = live(conn, "/members")
+
+ state = :sys.get_state(view.pid)
+ boolean_custom_fields = state.socket.assigns.boolean_custom_fields
+
+ # Should only contain boolean fields
+ assert length(boolean_custom_fields) == 2
+ assert Enum.all?(boolean_custom_fields, &(&1.value_type == :boolean))
+ assert Enum.any?(boolean_custom_fields, &(&1.id == boolean_field1.id))
+ assert Enum.any?(boolean_custom_fields, &(&1.id == boolean_field2.id))
+ end
+
+ test "mount sorts boolean custom fields by name ascending", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+
+ # Create boolean fields with specific names to test sorting
+ _boolean_field_z = create_boolean_custom_field(%{name: "Zebra Field"})
+ _boolean_field_a = create_boolean_custom_field(%{name: "Alpha Field"})
+ _boolean_field_m = create_boolean_custom_field(%{name: "Middle Field"})
+
+ {:ok, view, _html} = live(conn, "/members")
+
+ state = :sys.get_state(view.pid)
+ boolean_custom_fields = state.socket.assigns.boolean_custom_fields
+
+ # Should be sorted by name ascending
+ names = Enum.map(boolean_custom_fields, & &1.name)
+ assert names == ["Alpha Field", "Middle Field", "Zebra Field"]
+ end
+
+ test "handle_params parses bf_ values correctly", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ boolean_field = create_boolean_custom_field()
+
+ # Test true value
+ {:ok, view1, _html} =
+ live(conn, "/members?bf_#{boolean_field.id}=true")
+
+ state1 = :sys.get_state(view1.pid)
+ filters1 = state1.socket.assigns.boolean_custom_field_filters
+ assert filters1[boolean_field.id] == true
+ refute filters1[boolean_field.id] == "true"
+
+ # Test false value
+ {:ok, view2, _html} =
+ live(conn, "/members?bf_#{boolean_field.id}=false")
+
+ state2 = :sys.get_state(view2.pid)
+ filters2 = state2.socket.assigns.boolean_custom_field_filters
+ assert filters2[boolean_field.id] == false
+ refute filters2[boolean_field.id] == "false"
+ end
+
+ test "handle_params ignores non-existent custom field IDs", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ fake_id = Ecto.UUID.generate()
+
+ {:ok, view, _html} =
+ live(conn, "/members?bf_#{fake_id}=true")
+
+ state = :sys.get_state(view.pid)
+ filters = state.socket.assigns.boolean_custom_field_filters
+
+ # Filter should not be added for non-existent custom field
+ refute Map.has_key?(filters, fake_id)
+ assert filters == %{}
+ end
+
+ test "handle_params ignores non-boolean custom fields", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ string_field = create_string_custom_field()
+
+ {:ok, view, _html} =
+ live(conn, "/members?bf_#{string_field.id}=true")
+
+ state = :sys.get_state(view.pid)
+ filters = state.socket.assigns.boolean_custom_field_filters
+
+ # Filter should not be added for non-boolean custom field
+ refute Map.has_key?(filters, string_field.id)
+ assert filters == %{}
+ end
+
+ test "handle_params ignores invalid filter values", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ boolean_field = create_boolean_custom_field()
+
+ # Test various invalid values
+ invalid_values = ["1", "0", "yes", "no", "True", "False", "", "invalid", "null"]
+
+ for invalid_value <- invalid_values do
+ {:ok, view, _html} =
+ live(conn, "/members?bf_#{boolean_field.id}=#{invalid_value}")
+
+ state = :sys.get_state(view.pid)
+ filters = state.socket.assigns.boolean_custom_field_filters
+
+ # Invalid values should not be added to filters
+ refute Map.has_key?(filters, boolean_field.id),
+ "Invalid value '#{invalid_value}' should not be added to filters"
+ end
+ end
+
+ test "handle_params handles multiple boolean filters simultaneously", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ boolean_field1 = create_boolean_custom_field()
+ boolean_field2 = create_boolean_custom_field()
+
+ {:ok, view, _html} =
+ live(
+ conn,
+ "/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false"
+ )
+
+ state = :sys.get_state(view.pid)
+ filters = state.socket.assigns.boolean_custom_field_filters
+
+ assert filters[boolean_field1.id] == true
+ assert filters[boolean_field2.id] == false
+ assert map_size(filters) == 2
+ end
+
+ test "build_query_params includes active boolean filters and excludes nil filters", %{
+ conn: conn
+ } do
+ conn = conn_with_oidc_user(conn)
+ boolean_field1 = create_boolean_custom_field()
+ boolean_field2 = create_boolean_custom_field()
+
+ # Test with active filters
+ {:ok, view1, _html} =
+ live(
+ conn,
+ "/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false"
+ )
+
+ # Trigger a search to see if filters are preserved in URL
+ view1
+ |> element("[data-testid='search-input']")
+ |> render_change(%{value: "test"})
+
+ # Check that the patch includes boolean filters
+ path1 = assert_patch(view1)
+ assert path1 =~ "bf_#{boolean_field1.id}=true"
+ assert path1 =~ "bf_#{boolean_field2.id}=false"
+
+ # Test without filters (nil filters should not appear in URL)
+ {:ok, view2, _html} = live(conn, "/members")
+
+ # Trigger a search
+ view2
+ |> element("[data-testid='search-input']")
+ |> render_change(%{value: "test"})
+
+ # Check that no bf_ params are in URL
+ path2 = assert_patch(view2)
+ refute path2 =~ "bf_"
+ end
+
+ test "boolean filters are preserved during navigation actions", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ boolean_field = create_boolean_custom_field()
+
+ {:ok, view, _html} =
+ live(conn, "/members?bf_#{boolean_field.id}=true")
+
+ # Test sort toggle preserves filter
+ view
+ |> element("[data-testid='email']")
+ |> render_click()
+
+ path1 = assert_patch(view)
+ assert path1 =~ "bf_#{boolean_field.id}=true"
+
+ # Test search change preserves filter
+ view
+ |> element("[data-testid='search-input']")
+ |> render_change(%{value: "test"})
+
+ path2 = assert_patch(view)
+ assert path2 =~ "bf_#{boolean_field.id}=true"
+ end
+
+ test "boolean filters work together with cycle_status_filter", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ boolean_field = create_boolean_custom_field()
+
+ {:ok, view, _html} =
+ live(
+ conn,
+ "/members?cycle_status_filter=paid&bf_#{boolean_field.id}=true"
+ )
+
+ state = :sys.get_state(view.pid)
+ filters = state.socket.assigns.boolean_custom_field_filters
+
+ # Both filters should be set
+ assert filters[boolean_field.id] == true
+ assert state.socket.assigns.cycle_status_filter == :paid
+
+ # Both should be in URL when triggering search
+ view
+ |> element("[data-testid='search-input']")
+ |> render_change(%{value: "test"})
+
+ path = assert_patch(view)
+ assert path =~ "cycle_status_filter=paid"
+ assert path =~ "bf_#{boolean_field.id}=true"
+ end
+
+ test "handle_params removes filter when custom field is deleted", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ boolean_field = create_boolean_custom_field()
+
+ # Set up filter via URL
+ {:ok, view, _html} =
+ live(conn, "/members?bf_#{boolean_field.id}=true")
+
+ state_before = :sys.get_state(view.pid)
+ filters_before = state_before.socket.assigns.boolean_custom_field_filters
+ assert filters_before[boolean_field.id] == true
+
+ # Delete the custom field
+ Ash.destroy!(boolean_field)
+
+ # Navigate again - filter should be removed since custom field no longer exists
+ {:ok, view2, _html} =
+ live(conn, "/members?bf_#{boolean_field.id}=true")
+
+ state_after = :sys.get_state(view2.pid)
+ filters_after = state_after.socket.assigns.boolean_custom_field_filters
+
+ # Filter should not be present for deleted custom field
+ refute Map.has_key?(filters_after, boolean_field.id)
+ assert filters_after == %{}
+ end
+
+ test "handle_params handles URL-encoded custom field IDs correctly", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ boolean_field = create_boolean_custom_field()
+
+ # URL-encode the custom field ID (though UUIDs shouldn't need encoding normally)
+ encoded_id = URI.encode(boolean_field.id)
+
+ {:ok, view, _html} =
+ live(conn, "/members?bf_#{encoded_id}=true")
+
+ state = :sys.get_state(view.pid)
+ filters = state.socket.assigns.boolean_custom_field_filters
+
+ # Filter should work with URL-encoded ID
+ # Phoenix should decode it automatically, so we check with original ID
+ assert filters[boolean_field.id] == true
+ end
+
+ test "handle_params ignores malformed prefix (bf_bf_)", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ boolean_field = create_boolean_custom_field()
+
+ # Try to send parameter with double prefix
+ {:ok, view, _html} =
+ live(conn, "/members?bf_bf_#{boolean_field.id}=true")
+
+ state = :sys.get_state(view.pid)
+ filters = state.socket.assigns.boolean_custom_field_filters
+
+ # Should not parse as valid filter (UUID validation should fail)
+ refute Map.has_key?(filters, boolean_field.id)
+ assert filters == %{}
+ end
+
+ test "handle_params limits number of boolean filters to prevent DoS", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+
+ # Create 60 boolean custom fields (more than the limit)
+ boolean_fields = Enum.map(1..60, fn _ -> create_boolean_custom_field() end)
+
+ # Build URL with all 60 filters
+ filter_params =
+ Enum.map_join(boolean_fields, "&", fn cf -> "bf_#{cf.id}=true" end)
+
+ {:ok, view, _html} = live(conn, "/members?#{filter_params}")
+
+ state = :sys.get_state(view.pid)
+ filters = state.socket.assigns.boolean_custom_field_filters
+
+ # Should limit to maximum 50 filters
+ assert map_size(filters) <= 50
+ # All filters in the result should be valid
+ Enum.each(filters, fn {id, value} ->
+ assert value in [true, false]
+ # Verify the ID corresponds to one of our boolean fields
+ assert id in Enum.map(boolean_fields, &to_string(&1.id))
+ end)
+ end
+
+ test "handle_params ignores extremely long custom field IDs", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ boolean_field = create_boolean_custom_field()
+
+ # Create a fake ID that's way too long (UUIDs are max 36 chars)
+ fake_long_id = String.duplicate("a", 100)
+
+ {:ok, view, _html} =
+ live(conn, "/members?bf_#{fake_long_id}=true")
+
+ state = :sys.get_state(view.pid)
+ filters = state.socket.assigns.boolean_custom_field_filters
+
+ # Should not accept the extremely long ID
+ refute Map.has_key?(filters, fake_long_id)
+ # Valid boolean field should still work
+ refute Map.has_key?(filters, boolean_field.id)
+ assert filters == %{}
+ end
+
+ # Helper to create a member with a boolean custom field value
+ defp create_member_with_boolean_value(member_attrs, custom_field, value, actor) do
+ {:ok, member} =
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(
+ :create_member,
+ %{
+ first_name: "Test",
+ last_name: "Member",
+ email: "test.member.#{System.unique_integer([:positive])}@example.com"
+ }
+ |> Map.merge(member_attrs)
+ )
+ |> Ash.create(actor: actor)
+
+ {:ok, _cfv} =
+ Mv.Membership.CustomFieldValue
+ |> Ash.Changeset.for_create(:create, %{
+ member_id: member.id,
+ custom_field_id: custom_field.id,
+ value: %{"_union_type" => "boolean", "_union_value" => value}
+ })
+ |> Ash.create(actor: actor)
+
+ # Reload member with custom field values
+ member
+ |> 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, system_actor)
+
+ # Test the function (will fail until implemented)
+ result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
+
+ assert result == true
+ 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, system_actor)
+
+ result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
+
+ assert result == false
+ end
+
+ 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} =
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "Test",
+ last_name: "Member",
+ email: "test.member.#{System.unique_integer([:positive])}@example.com"
+ })
+ |> Ash.create(actor: system_actor)
+
+ # Create CustomFieldValue with map format (Ash expects _union_type and _union_value)
+ {:ok, _cfv} =
+ Mv.Membership.CustomFieldValue
+ |> Ash.Changeset.for_create(:create, %{
+ member_id: member.id,
+ custom_field_id: boolean_field.id,
+ value: %{"_union_type" => "boolean", "_union_value" => true}
+ })
+ |> Ash.create(actor: system_actor)
+
+ # Reload member with 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)
+
+ assert result == true
+ end
+
+ 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} =
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "Test",
+ last_name: "Member",
+ email: "test.member.#{System.unique_integer([:positive])}@example.com"
+ })
+ |> Ash.create(actor: system_actor)
+
+ # Member has no custom field value for this field
+ member = member |> Ash.load!(:custom_field_values, actor: system_actor)
+
+ result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
+
+ assert result == nil
+ end
+
+ 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} =
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "Test",
+ last_name: "Member",
+ email: "test.member.#{System.unique_integer([:positive])}@example.com"
+ })
+ |> Ash.create(actor: system_actor)
+
+ # Create CustomFieldValue with nil value (edge case)
+ {:ok, _cfv} =
+ Mv.Membership.CustomFieldValue
+ |> Ash.Changeset.for_create(:create, %{
+ member_id: member.id,
+ custom_field_id: boolean_field.id,
+ value: nil
+ })
+ |> Ash.create(actor: system_actor)
+
+ member = member |> Ash.load!(:custom_field_values, actor: system_actor)
+
+ result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
+
+ assert result == nil
+ end
+
+ 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()
+
+ {:ok, member} =
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "Test",
+ last_name: "Member",
+ email: "test.member.#{System.unique_integer([:positive])}@example.com"
+ })
+ |> Ash.create(actor: system_actor)
+
+ # Create string custom field value (not boolean)
+ {:ok, _cfv} =
+ Mv.Membership.CustomFieldValue
+ |> Ash.Changeset.for_create(:create, %{
+ member_id: member.id,
+ custom_field_id: string_field.id,
+ value: %{"_union_type" => "string", "_union_value" => "test"}
+ })
+ |> Ash.create(actor: system_actor)
+
+ 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)
+
+ assert result == nil
+ end
+
+ # 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,
+ system_actor
+ )
+
+ member_with_false =
+ create_member_with_boolean_value(
+ %{first_name: "FalseMember"},
+ boolean_field,
+ false,
+ system_actor
+ )
+
+ {:ok, member_without_value} =
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "NoValue",
+ last_name: "Member",
+ email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
+ })
+ |> Ash.create(actor: system_actor)
+
+ member_without_value = member_without_value |> Ash.load!(:custom_field_values)
+
+ members = [member_with_true, member_with_false, member_without_value]
+ filters = %{to_string(boolean_field.id) => true}
+ all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
+
+ result =
+ MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
+ members,
+ filters,
+ all_custom_fields
+ )
+
+ assert length(result) == 1
+ assert List.first(result).id == member_with_true.id
+ refute Enum.any?(result, &(&1.id == member_with_false.id))
+ refute Enum.any?(result, &(&1.id == member_without_value.id))
+ end
+
+ 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,
+ system_actor
+ )
+
+ member_with_false =
+ create_member_with_boolean_value(
+ %{first_name: "FalseMember"},
+ boolean_field,
+ false,
+ system_actor
+ )
+
+ {:ok, member_without_value} =
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "NoValue",
+ last_name: "Member",
+ email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
+ })
+ |> Ash.create(actor: system_actor)
+
+ 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}
+ all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
+
+ result =
+ MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
+ members,
+ filters,
+ all_custom_fields
+ )
+
+ assert length(result) == 1
+ assert List.first(result).id == member_with_false.id
+ refute Enum.any?(result, &(&1.id == member_with_true.id))
+ refute Enum.any?(result, &(&1.id == member_without_value.id))
+ end
+
+ 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,
+ system_actor
+ )
+
+ member2 =
+ create_member_with_boolean_value(
+ %{first_name: "Member2"},
+ boolean_field,
+ false,
+ system_actor
+ )
+
+ members = [member1, member2]
+ filters = %{}
+ all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
+
+ result =
+ MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
+ members,
+ filters,
+ all_custom_fields
+ )
+
+ assert length(result) == 2
+
+ assert Enum.all?([member1.id, member2.id], fn id ->
+ Enum.any?(result, &(&1.id == id))
+ end)
+ end
+
+ 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"})
+
+ # Member with both fields = true
+ {:ok, member_both_true} =
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "BothTrue",
+ last_name: "Member",
+ email: "bothtrue.member.#{System.unique_integer([:positive])}@example.com"
+ })
+ |> Ash.create(actor: system_actor)
+
+ {:ok, _cfv1} =
+ Mv.Membership.CustomFieldValue
+ |> Ash.Changeset.for_create(:create, %{
+ member_id: member_both_true.id,
+ custom_field_id: boolean_field1.id,
+ value: %{"_union_type" => "boolean", "_union_value" => true}
+ })
+ |> Ash.create(actor: system_actor)
+
+ {:ok, _cfv2} =
+ Mv.Membership.CustomFieldValue
+ |> Ash.Changeset.for_create(:create, %{
+ member_id: member_both_true.id,
+ custom_field_id: boolean_field2.id,
+ value: %{"_union_type" => "boolean", "_union_value" => true}
+ })
+ |> Ash.create(actor: system_actor)
+
+ member_both_true = member_both_true |> Ash.load!(:custom_field_values, actor: system_actor)
+
+ # Member with field1 = true, field2 = false
+ {:ok, member_mixed} =
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "Mixed",
+ last_name: "Member",
+ email: "mixed.member.#{System.unique_integer([:positive])}@example.com"
+ })
+ |> Ash.create(actor: system_actor)
+
+ {:ok, _cfv3} =
+ Mv.Membership.CustomFieldValue
+ |> Ash.Changeset.for_create(:create, %{
+ member_id: member_mixed.id,
+ custom_field_id: boolean_field1.id,
+ value: %{"_union_type" => "boolean", "_union_value" => true}
+ })
+ |> Ash.create(actor: system_actor)
+
+ {:ok, _cfv4} =
+ Mv.Membership.CustomFieldValue
+ |> Ash.Changeset.for_create(:create, %{
+ member_id: member_mixed.id,
+ custom_field_id: boolean_field2.id,
+ value: %{"_union_type" => "boolean", "_union_value" => false}
+ })
+ |> Ash.create(actor: system_actor)
+
+ member_mixed = member_mixed |> Ash.load!(:custom_field_values, actor: system_actor)
+
+ members = [member_both_true, member_mixed]
+
+ filters = %{
+ to_string(boolean_field1.id) => true,
+ to_string(boolean_field2.id) => true
+ }
+
+ all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
+
+ result =
+ MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
+ members,
+ filters,
+ all_custom_fields
+ )
+
+ # Only member_both_true should match (both fields = true)
+ assert length(result) == 1
+ assert List.first(result).id == member_both_true.id
+ end
+
+ 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,
+ system_actor
+ )
+
+ members = [member]
+ filters = %{fake_id => true}
+ all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
+
+ result =
+ MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
+ members,
+ filters,
+ all_custom_fields
+ )
+
+ # Should return all members since fake_id doesn't match any custom field
+ assert length(result) == 1
+ end
+
+ # 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,
+ system_actor
+ )
+
+ _member_with_false =
+ create_member_with_boolean_value(
+ %{first_name: "FalseMember"},
+ boolean_field,
+ false,
+ system_actor
+ )
+
+ {:ok, _member_without_value} =
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "NoValue",
+ last_name: "Member",
+ email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
+ })
+ |> Ash.create(actor: system_actor)
+
+ # Test true filter
+ {:ok, _view, html_true} =
+ live(conn, "/members?bf_#{boolean_field.id}=true")
+
+ assert html_true =~ "TrueMember"
+ refute html_true =~ "FalseMember"
+ refute html_true =~ "NoValue"
+
+ # Test false filter
+ {:ok, _view, html_false} =
+ live(conn, "/members?bf_#{boolean_field.id}=false")
+
+ assert html_false =~ "FalseMember"
+ refute html_false =~ "TrueMember"
+ refute html_false =~ "NoValue"
+ 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}, system_actor)
+ today = Date.utc_today()
+ last_year_start = Date.new!(today.year - 1, 1, 1)
+
+ # Member with true boolean value and paid status
+ {:ok, member_paid_true} =
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "PaidTrue",
+ last_name: "Member",
+ email: "paidtrue.member.#{System.unique_integer([:positive])}@example.com",
+ membership_fee_type_id: fee_type.id
+ })
+ |> Ash.create(actor: system_actor)
+
+ {:ok, _cfv} =
+ Mv.Membership.CustomFieldValue
+ |> Ash.Changeset.for_create(:create, %{
+ member_id: member_paid_true.id,
+ custom_field_id: boolean_field.id,
+ value: %{"_union_type" => "boolean", "_union_value" => true}
+ })
+ |> Ash.create(actor: system_actor)
+
+ 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} =
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "UnpaidTrue",
+ last_name: "Member",
+ email: "unpaidtrue.member.#{System.unique_integer([:positive])}@example.com",
+ membership_fee_type_id: fee_type.id
+ })
+ |> Ash.create(actor: system_actor)
+
+ {:ok, _cfv2} =
+ Mv.Membership.CustomFieldValue
+ |> Ash.Changeset.for_create(:create, %{
+ member_id: member_unpaid_true.id,
+ custom_field_id: boolean_field.id,
+ value: %{"_union_type" => "boolean", "_union_value" => true}
+ })
+ |> Ash.create(actor: system_actor)
+
+ create_cycle(
+ member_unpaid_true,
+ fee_type,
+ %{cycle_start: last_year_start, status: :unpaid},
+ system_actor
+ )
+
+ # Test both filters together
+ {:ok, _view, html} =
+ live(conn, "/members?cycle_status_filter=paid&bf_#{boolean_field.id}=true")
+
+ # Only member_paid_true should match both filters
+ assert html =~ "PaidTrue"
+ refute html =~ "UnpaidTrue"
+ 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,
+ system_actor
+ )
+
+ _member_with_false =
+ create_member_with_boolean_value(
+ %{first_name: "FalseMember"},
+ boolean_field,
+ false,
+ system_actor
+ )
+
+ # Test search + boolean filter
+ {:ok, _view, html} =
+ live(conn, "/members?query=TrueMember&bf_#{boolean_field.id}=true")
+
+ # Only member_with_true should match both search and filter
+ assert html =~ "TrueMember"
+ refute html =~ "FalseMember"
+ 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,
+ system_actor
+ )
+
+ _member_with_false =
+ create_member_with_boolean_value(
+ %{first_name: "FalseMember"},
+ boolean_field,
+ false,
+ system_actor
+ )
+
+ {:ok, _member_without_value} =
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "NoValue",
+ last_name: "Member",
+ email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
+ })
+ |> Ash.create(actor: system_actor)
+
+ # Test that filter works even though field is not visible in overview
+ {:ok, _view, html_true} =
+ live(conn, "/members?bf_#{boolean_field.id}=true")
+
+ assert html_true =~ "TrueMember"
+ refute html_true =~ "FalseMember"
+ refute html_true =~ "NoValue"
+
+ # Test false filter
+ {:ok, _view, html_false} =
+ live(conn, "/members?bf_#{boolean_field.id}=false")
+
+ assert html_false =~ "FalseMember"
+ refute html_false =~ "TrueMember"
+ refute html_false =~ "NoValue"
+ end
+
+ test "boolean custom field appears in filter dropdown after being added", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+
+ # Start with no boolean custom fields
+ {:ok, view, _html} = live(conn, "/members")
+
+ state_before = :sys.get_state(view.pid)
+ boolean_fields_before = state_before.socket.assigns.boolean_custom_fields
+ assert boolean_fields_before == []
+
+ # Create a new boolean custom field
+ new_boolean_field = create_boolean_custom_field(%{name: "Newly Added Field"})
+
+ # Navigate again - the new field should appear
+ {:ok, view2, _html} = live(conn, "/members")
+
+ state_after = :sys.get_state(view2.pid)
+ boolean_fields_after = state_after.socket.assigns.boolean_custom_fields
+
+ # New boolean field should be present
+ assert length(boolean_fields_after) == 1
+ assert Enum.any?(boolean_fields_after, &(&1.id == new_boolean_field.id))
+ assert Enum.any?(boolean_fields_after, &(&1.name == "Newly Added Field"))
+ 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()
+
+ # Create 150 members - 75 with true, 75 with false
+ members_with_true =
+ Enum.map(1..75, fn i ->
+ create_member_with_boolean_value(
+ %{
+ first_name: "TrueMember#{i}",
+ email: "truemember#{i}@example.com"
+ },
+ boolean_field,
+ true,
+ system_actor
+ )
+ end)
+
+ members_with_false =
+ Enum.map(1..75, fn i ->
+ create_member_with_boolean_value(
+ %{
+ first_name: "FalseMember#{i}",
+ email: "falsemember#{i}@example.com"
+ },
+ boolean_field,
+ false,
+ system_actor
+ )
+ end)
+
+ # Verify all members were created
+ assert length(members_with_true) == 75
+ assert length(members_with_false) == 75
+
+ # Test filter performance - should complete in reasonable time (< 1 second)
+ start_time = System.monotonic_time(:millisecond)
+
+ {:ok, _view, html} =
+ live(conn, "/members?bf_#{boolean_field.id}=true")
+
+ end_time = System.monotonic_time(:millisecond)
+ duration = end_time - start_time
+
+ # Should complete in less than 1 second (1000ms)
+ assert duration < 1000, "Filter took #{duration}ms, expected < 1000ms"
+
+ # Verify filtering worked correctly - should show all true members
+ Enum.each(1..75, fn i ->
+ assert html =~ "TrueMember#{i}"
+ end)
+
+ # Should not show false members
+ Enum.each(1..75, fn i ->
+ refute html =~ "FalseMember#{i}"
+ end)
+ end
+ end
end
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..29726ef 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,17 +168,17 @@ 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)
+ {:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: system_actor)
user_with_role
end