Merge pull request '[Refactor] Remove NoActor bypass' (#367) from refactor/remove_noactor into main
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #367
This commit is contained in:
commit
9fe872ee58
87 changed files with 5037 additions and 3305 deletions
|
|
@ -690,16 +690,9 @@ end
|
||||||
|
|
||||||
**Authorization Bootstrap Patterns:**
|
**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
|
1. **system_actor** (systemic operations) - Admin user for operations that must always succeed
|
||||||
```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
|
|
||||||
```elixir
|
```elixir
|
||||||
# Good: Systemic operation
|
# Good: Systemic operation
|
||||||
system_actor = SystemActor.get_system_actor()
|
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!
|
# 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
|
```elixir
|
||||||
# Good: Bootstrap (seeds, SystemActor loading)
|
# Good: Bootstrap (seeds, SystemActor loading)
|
||||||
Accounts.create_user!(%{email: admin_email}, authorize?: false)
|
Accounts.create_user!(%{email: admin_email}, authorize?: false)
|
||||||
|
|
@ -719,10 +712,10 @@ Three mechanisms exist for bypassing standard authorization:
|
||||||
```
|
```
|
||||||
|
|
||||||
**Decision Guide:**
|
**Decision Guide:**
|
||||||
- Use **NoActor** for test fixtures (automatic via config)
|
- Use **system_actor** for email sync, cycle generation, validations, and test fixtures
|
||||||
- Use **system_actor** for email sync, cycle generation, validations
|
|
||||||
- Use **authorize?: false** only for bootstrap (seeds, circular dependencies)
|
- Use **authorize?: false** only for bootstrap (seeds, circular dependencies)
|
||||||
- Always document why `authorize?: false` is necessary
|
- 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)
|
**See also:** `docs/roles-and-permissions-architecture.md` (Authorization Bootstrap Patterns section)
|
||||||
|
|
||||||
|
|
@ -1702,65 +1695,72 @@ case Ash.read(Mv.Membership.Member, actor: actor) do
|
||||||
end
|
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:**
|
**Exception: AshAuthentication Bypass Tests**
|
||||||
|
|
||||||
- Allows CRUD operations without an actor in **test environment only**
|
Tests that verify the AshAuthentication bypass mechanism are a **conscious exception**. These tests must verify that registration/login works **without an actor** via the `AshAuthenticationInteraction` check. To enable this bypass in tests, set the context explicitly:
|
||||||
- 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:**
|
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
# config/test.exs
|
# ✅ GOOD - Testing AshAuthentication bypass (conscious exception)
|
||||||
config :mv, :allow_no_actor_bypass, true
|
changeset =
|
||||||
|
Accounts.User
|
||||||
|
|> Ash.Changeset.for_create(:register_with_password, %{...})
|
||||||
|
|> Ash.Changeset.set_context(%{private: %{ash_authentication?: true}})
|
||||||
|
|
||||||
# lib/mv/authorization/checks/no_actor.ex
|
{:ok, user} = Ash.create(changeset) # No actor - tests bypass mechanism
|
||||||
# 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)
|
# ❌ BAD - Using system_actor masks the bypass test
|
||||||
def match?(nil, _context, _opts) do
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
@allow_no_actor_bypass # true in test, false in prod/dev
|
Ash.create(changeset, actor: system_actor) # Tests admin permissions, not bypass!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test Fixtures:**
|
||||||
|
|
||||||
|
All test fixtures use `system_actor` for authorization:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
# 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
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
**Why This Pattern Exists:**
|
**Why Explicit Actors in Tests:**
|
||||||
|
|
||||||
- Test fixtures often need to create resources without an actor
|
- Prevents masking authorization bugs
|
||||||
- Production operations MUST always have an actor for security
|
- Makes authorization requirements explicit
|
||||||
- Config-based guard (not Mix.env) ensures release-safety
|
- Tests fail if authorization is broken (fail-fast)
|
||||||
- Defaults to `false` (fail-closed) if config not set
|
- Consistent with production code patterns
|
||||||
|
|
||||||
**NEVER Use NoActor in Production:**
|
**Using system_actor in Tests:**
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
# ❌ BAD - Don't do this in production code
|
# ✅ GOOD - Explicit actor in tests
|
||||||
Ash.create!(Member, attrs) # No actor - will fail in prod
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# ✅ 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()
|
|
||||||
Ash.create!(Member, attrs, actor: 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
|
Use `authorize?: false` only for bootstrap scenarios (seeds, SystemActor initialization):
|
||||||
- Production safety is guaranteed by config (only set in test.exs, defaults to false)
|
|
||||||
- See `test/mv/authorization/checks/no_actor_test.exs`
|
```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
|
### 5.2 Password Security
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,10 @@ config :mv, Mv.Repo,
|
||||||
port: System.get_env("TEST_POSTGRES_PORT", "5000"),
|
port: System.get_env("TEST_POSTGRES_PORT", "5000"),
|
||||||
database: "mv_test#{System.get_env("MIX_TEST_PARTITION")}",
|
database: "mv_test#{System.get_env("MIX_TEST_PARTITION")}",
|
||||||
pool: Ecto.Adapters.SQL.Sandbox,
|
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,
|
# We don't run a server during test. If one is required,
|
||||||
# you can enable the server option below.
|
# you can enable the server option below.
|
||||||
|
|
@ -49,7 +52,3 @@ config :mv, :require_token_presence_for_authentication, false
|
||||||
# Enable SQL Sandbox for async LiveView tests
|
# Enable SQL Sandbox for async LiveView tests
|
||||||
# This flag controls sync vs async behavior in CycleGenerator after_action hooks
|
# This flag controls sync vs async behavior in CycleGenerator after_action hooks
|
||||||
config :mv, :sql_sandbox, true
|
config :mv, :sql_sandbox, true
|
||||||
|
|
||||||
# Allow operations without actor in test environment (NoActor check)
|
|
||||||
# SECURITY: This must ONLY be true in test.exs, never in prod/dev
|
|
||||||
config :mv, :allow_no_actor_bypass, true
|
|
||||||
|
|
|
||||||
|
|
@ -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`
|
- ✅ UPDATE operations via HasPermission with `scope :own`
|
||||||
- ✅ Admin operations via HasPermission with `scope :all`
|
- ✅ Admin operations via HasPermission with `scope :all`
|
||||||
- ✅ AshAuthentication bypass (registration/login)
|
- ✅ AshAuthentication bypass (registration/login)
|
||||||
- ✅ NoActor bypass (test environment)
|
- ✅ Tests use system_actor for authorization
|
||||||
|
|
||||||
**Key Tests Proving Pattern:**
|
**Key Tests Proving Pattern:**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -946,12 +946,7 @@ defmodule Mv.Accounts.User do
|
||||||
authorize_if always()
|
authorize_if always()
|
||||||
end
|
end
|
||||||
|
|
||||||
# 2. NoActor Bypass (test environment only, for test fixtures)
|
# 2. SPECIAL CASE: Users can always READ their own account
|
||||||
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
|
|
||||||
# Bypass needed for list queries (expr() triggers auto_filter in Ash)
|
# Bypass needed for list queries (expr() triggers auto_filter in Ash)
|
||||||
# UPDATE is handled by HasPermission below (scope :own works with changesets)
|
# UPDATE is handled by HasPermission below (scope :own works with changesets)
|
||||||
bypass action_type(:read) do
|
bypass action_type(:read) do
|
||||||
|
|
@ -959,7 +954,7 @@ defmodule Mv.Accounts.User do
|
||||||
authorize_if expr(id == ^actor(:id))
|
authorize_if expr(id == ^actor(:id))
|
||||||
end
|
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)
|
# - :own_data → can UPDATE own user (scope :own via HasPermission)
|
||||||
# - :read_only → 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)
|
# - :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
|
authorize_if Mv.Authorization.Checks.HasPermission
|
||||||
end
|
end
|
||||||
|
|
||||||
# 5. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed)
|
# 4. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed)
|
||||||
end
|
end
|
||||||
|
|
||||||
# ...
|
# ...
|
||||||
|
|
@ -1007,12 +1002,7 @@ defmodule Mv.Membership.Member do
|
||||||
use Ash.Resource, ...
|
use Ash.Resource, ...
|
||||||
|
|
||||||
policies do
|
policies do
|
||||||
# 1. NoActor Bypass (test environment only, for test fixtures)
|
# 1. SPECIAL CASE: Users can always READ their linked member
|
||||||
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
|
|
||||||
# Bypass needed for list queries (expr() triggers auto_filter in Ash)
|
# Bypass needed for list queries (expr() triggers auto_filter in Ash)
|
||||||
# UPDATE is handled by HasPermission below (scope :linked works with changesets)
|
# UPDATE is handled by HasPermission below (scope :linked works with changesets)
|
||||||
bypass action_type(:read) do
|
bypass action_type(:read) do
|
||||||
|
|
@ -1020,7 +1010,7 @@ defmodule Mv.Membership.Member do
|
||||||
authorize_if expr(id == ^actor(:member_id))
|
authorize_if expr(id == ^actor(:member_id))
|
||||||
end
|
end
|
||||||
|
|
||||||
# 3. GENERAL: Check permissions from role
|
# 2. GENERAL: Check permissions from role
|
||||||
# - :own_data → can UPDATE linked member (scope :linked via HasPermission)
|
# - :own_data → can UPDATE linked member (scope :linked via HasPermission)
|
||||||
# - :read_only → can READ all members (scope :all), no update permission
|
# - :read_only → can READ all members (scope :all), no update permission
|
||||||
# - :normal_user → can CRUD all members (scope :all)
|
# - :normal_user → can CRUD all members (scope :all)
|
||||||
|
|
@ -2629,45 +2619,16 @@ This section clarifies three different mechanisms for bypassing standard authori
|
||||||
|
|
||||||
### Overview
|
### Overview
|
||||||
|
|
||||||
The codebase uses three authorization bypass mechanisms:
|
The codebase uses two authorization bypass mechanisms:
|
||||||
|
|
||||||
1. **NoActor** - Test-only bypass (compile-time secured)
|
1. **system_actor** - Admin user for systemic operations
|
||||||
2. **system_actor** - Admin user for systemic operations
|
2. **authorize?: false** - Bootstrap bypass for circular dependencies
|
||||||
3. **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.
|
### 1. System Actor
|
||||||
|
|
||||||
**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
|
|
||||||
|
|
||||||
**Purpose:** Admin user for systemic operations that must always succeed regardless of user permissions.
|
**Purpose:** Admin user for systemic operations that must always succeed regardless of user permissions.
|
||||||
|
|
||||||
|
|
@ -2708,7 +2669,7 @@ end
|
||||||
- Consistent authorization flow
|
- Consistent authorization flow
|
||||||
- Testable
|
- Testable
|
||||||
|
|
||||||
### 3. authorize?: false
|
### 2. authorize?: false
|
||||||
|
|
||||||
**Purpose:** Skip policies for bootstrap scenarios with circular dependencies.
|
**Purpose:** Skip policies for bootstrap scenarios with circular dependencies.
|
||||||
|
|
||||||
|
|
@ -2759,21 +2720,17 @@ Mv.Authorization.Role
|
||||||
|
|
||||||
### Comparison
|
### Comparison
|
||||||
|
|
||||||
| Aspect | NoActor | system_actor | authorize?: false |
|
| Aspect | system_actor | authorize?: false |
|
||||||
|--------|---------|--------------|-------------------|
|
|--------|--------------|-------------------|
|
||||||
| **Environment** | Test only | All | All |
|
| **Environment** | All | All |
|
||||||
| **Actor** | nil | Admin user | nil |
|
| **Actor** | Admin user | nil |
|
||||||
| **Policies** | Bypassed | Evaluated | Skipped |
|
| **Policies** | Evaluated | Skipped |
|
||||||
| **Audit Trail** | No | Yes (system@mila.local) | No |
|
| **Audit Trail** | Yes (system@mila.local) | No |
|
||||||
| **Use Case** | Test fixtures | Systemic operations | Bootstrap |
|
| **Use Case** | Systemic operations, test fixtures | Bootstrap |
|
||||||
| **Explicit?** | Policy bypass | Function call | Query option |
|
| **Explicit?** | Function call | Query option |
|
||||||
|
|
||||||
### Decision Guide
|
### Decision Guide
|
||||||
|
|
||||||
**Use NoActor when:**
|
|
||||||
- ✅ Writing test fixtures
|
|
||||||
- ✅ Compile-time guard ensures test-only
|
|
||||||
|
|
||||||
**Use system_actor when:**
|
**Use system_actor when:**
|
||||||
- ✅ Systemic operation must always succeed
|
- ✅ Systemic operation must always succeed
|
||||||
- ✅ Email synchronization
|
- ✅ Email synchronization
|
||||||
|
|
@ -2789,7 +2746,7 @@ Mv.Authorization.Role
|
||||||
**DON'T:**
|
**DON'T:**
|
||||||
- ❌ Use `authorize?: false` for user-initiated actions
|
- ❌ Use `authorize?: false` for user-initiated actions
|
||||||
- ❌ Use `authorize?: false` when `system_actor` would work
|
- ❌ 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
|
### The Circular Dependency Problem
|
||||||
|
|
||||||
|
|
@ -2873,7 +2830,8 @@ end
|
||||||
- Enhanced edge case documentation
|
- Enhanced edge case documentation
|
||||||
|
|
||||||
**Changes from V2.0:**
|
**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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -542,7 +542,7 @@ Following the same pattern as Member resource:
|
||||||
1. ✅ Open `lib/accounts/user.ex`
|
1. ✅ Open `lib/accounts/user.ex`
|
||||||
2. ✅ Add `policies` block
|
2. ✅ Add `policies` block
|
||||||
3. ✅ Add AshAuthentication bypass (registration/login without actor)
|
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
|
5. ✅ Add bypass for READ: Allow user to always read their own account
|
||||||
```elixir
|
```elixir
|
||||||
bypass action_type(:read) do
|
bypass action_type(:read) do
|
||||||
|
|
@ -556,10 +556,11 @@ Following the same pattern as Member resource:
|
||||||
|
|
||||||
**Policy Order:**
|
**Policy Order:**
|
||||||
1. ✅ AshAuthentication bypass (registration/login)
|
1. ✅ AshAuthentication bypass (registration/login)
|
||||||
2. ✅ NoActor bypass (test environment)
|
2. ✅ Bypass: User can READ own account (id == actor.id)
|
||||||
3. ✅ Bypass: User can READ own account (id == actor.id)
|
3. ✅ HasPermission: General permission check (UPDATE uses scope :own, admin uses scope :all)
|
||||||
4. ✅ HasPermission: General permission check (UPDATE uses scope :own, admin uses scope :all)
|
4. ✅ Default: Ash implicitly forbids (fail-closed)
|
||||||
5. ✅ 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?**
|
**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)
|
- ✅ User can always update own credentials (via HasPermission with scope :own)
|
||||||
- ✅ Only admin can read/update other users (scope :all)
|
- ✅ Only admin can read/update other users (scope :all)
|
||||||
- ✅ Only admin can destroy 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
|
- ✅ Actor preloads :role relationship
|
||||||
- ✅ All tests pass (30/31 pass, 1 skipped)
|
- ✅ 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)
|
- ✅ 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 all 4 permission sets: own_data, read_only, normal_user, admin
|
||||||
- ✅ Tests for AshAuthentication bypass (registration/login)
|
- ✅ 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)
|
- ✅ Tests verify scope :own is used for UPDATE (not redundant)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -22,18 +22,13 @@ policies do
|
||||||
authorize_if always()
|
authorize_if always()
|
||||||
end
|
end
|
||||||
|
|
||||||
# 2. NoActor Bypass (test environment only)
|
# 2. Bypass for READ (list queries via auto_filter)
|
||||||
bypass action_type([:create, :read, :update, :destroy]) do
|
|
||||||
authorize_if Mv.Authorization.Checks.NoActor
|
|
||||||
end
|
|
||||||
|
|
||||||
# 3. Bypass for READ (list queries via auto_filter)
|
|
||||||
bypass action_type(:read) do
|
bypass action_type(:read) do
|
||||||
description "Users can always read their own account"
|
description "Users can always read their own account"
|
||||||
authorize_if expr(id == ^actor(:id))
|
authorize_if expr(id == ^actor(:id))
|
||||||
end
|
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
|
policy action_type([:read, :create, :update, :destroy]) do
|
||||||
description "Check permissions from user's role and permission set"
|
description "Check permissions from user's role and permission set"
|
||||||
authorize_if Mv.Authorization.Checks.HasPermission
|
authorize_if Mv.Authorization.Checks.HasPermission
|
||||||
|
|
@ -51,7 +46,7 @@ end
|
||||||
- ✅ CREATE operations (admin only)
|
- ✅ CREATE operations (admin only)
|
||||||
- ✅ DESTROY operations (admin only)
|
- ✅ DESTROY operations (admin only)
|
||||||
- ✅ AshAuthentication bypass (registration/login)
|
- ✅ 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:**
|
**Test Environment:**
|
||||||
- ✅ Operations without actor work in test environment
|
- ✅ Operations without actor work in test environment
|
||||||
- ✅ NoActor bypass correctly detects compile-time environment
|
- ✅ All tests explicitly use system_actor for authorization
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -275,12 +275,6 @@ defmodule Mv.Accounts.User do
|
||||||
authorize_if always()
|
authorize_if always()
|
||||||
end
|
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)
|
# READ bypass for list queries (scope :own via expr)
|
||||||
bypass action_type(:read) do
|
bypass action_type(:read) do
|
||||||
description "Users can always read their own account"
|
description "Users can always read their own account"
|
||||||
|
|
|
||||||
|
|
@ -303,15 +303,6 @@ defmodule Mv.Membership.Member do
|
||||||
# Authorization Policies
|
# Authorization Policies
|
||||||
# Order matters: Most specific policies first, then general permission check
|
# Order matters: Most specific policies first, then general permission check
|
||||||
policies do
|
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
|
# SPECIAL CASE: Users can always READ their linked member
|
||||||
# This allows users with ANY permission set to read their own 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
|
# Check using the inverse relationship: User.member_id → Member.id
|
||||||
|
|
@ -402,11 +393,9 @@ defmodule Mv.Membership.Member do
|
||||||
user_id = user_arg[:id]
|
user_id = user_arg[:id]
|
||||||
current_member_id = changeset.data.id
|
current_member_id = changeset.data.id
|
||||||
|
|
||||||
# Get actor from changeset context for authorization
|
# Get actor from changeset context (may be nil)
|
||||||
# If no actor is present, this will fail in production (fail-closed)
|
|
||||||
actor = Map.get(changeset.context || %{}, :actor)
|
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
|
# Check if authorization is disabled in the parent operation's context
|
||||||
# Access private context where authorize? flag is stored
|
# Access private context where authorize? flag is stored
|
||||||
authorize? =
|
authorize? =
|
||||||
|
|
@ -415,8 +404,17 @@ defmodule Mv.Membership.Member do
|
||||||
_ -> true
|
_ -> true
|
||||||
end
|
end
|
||||||
|
|
||||||
# Pass actor and authorize? to ensure proper authorization (User might have policies in future)
|
# Use actor for authorization when available and authorize? is true
|
||||||
case Ash.get(Mv.Accounts.User, user_id, actor: actor, authorize?: authorize?) do
|
# Fall back to authorize?: false only for bootstrap/system operations
|
||||||
|
# This ensures normal operations respect authorization while system operations work
|
||||||
|
query_opts =
|
||||||
|
if actor && authorize? do
|
||||||
|
[actor: actor]
|
||||||
|
else
|
||||||
|
[authorize?: false]
|
||||||
|
end
|
||||||
|
|
||||||
|
case Ash.get(Mv.Accounts.User, user_id, query_opts) do
|
||||||
# User is free to be linked
|
# User is free to be linked
|
||||||
{:ok, %{member_id: nil}} ->
|
{:ok, %{member_id: nil}} ->
|
||||||
:ok
|
:ok
|
||||||
|
|
@ -429,6 +427,9 @@ defmodule Mv.Membership.Member do
|
||||||
# User is linked to a different member - prevent "stealing"
|
# User is linked to a different member - prevent "stealing"
|
||||||
{:error, field: :user, message: "User is already linked to another member"}
|
{: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, _} ->
|
||||||
{:error, field: :user, message: "User not found"}
|
{:error, field: :user, message: "User not found"}
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -85,10 +85,11 @@ defmodule Mv.MembershipFees.MembershipFeeType do
|
||||||
if changeset.action_type == :destroy do
|
if changeset.action_type == :destroy do
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
# Integrity check: count members without authorization (systemic operation)
|
||||||
member_count =
|
member_count =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Ash.Query.filter(membership_fee_type_id == ^changeset.data.id)
|
|> Ash.Query.filter(membership_fee_type_id == ^changeset.data.id)
|
||||||
|> Ash.count!()
|
|> Ash.count!(authorize?: false)
|
||||||
|
|
||||||
if member_count > 0 do
|
if member_count > 0 do
|
||||||
{:error,
|
{:error,
|
||||||
|
|
@ -108,10 +109,11 @@ defmodule Mv.MembershipFees.MembershipFeeType do
|
||||||
if changeset.action_type == :destroy do
|
if changeset.action_type == :destroy do
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
# Integrity check: count cycles without authorization (systemic operation)
|
||||||
cycle_count =
|
cycle_count =
|
||||||
Mv.MembershipFees.MembershipFeeCycle
|
Mv.MembershipFees.MembershipFeeCycle
|
||||||
|> Ash.Query.filter(membership_fee_type_id == ^changeset.data.id)
|
|> Ash.Query.filter(membership_fee_type_id == ^changeset.data.id)
|
||||||
|> Ash.count!()
|
|> Ash.count!(authorize?: false)
|
||||||
|
|
||||||
if cycle_count > 0 do
|
if cycle_count > 0 do
|
||||||
{:error,
|
{:error,
|
||||||
|
|
@ -131,10 +133,11 @@ defmodule Mv.MembershipFees.MembershipFeeType do
|
||||||
if changeset.action_type == :destroy do
|
if changeset.action_type == :destroy do
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
# Integrity check: count settings without authorization (systemic operation)
|
||||||
setting_count =
|
setting_count =
|
||||||
Mv.Membership.Setting
|
Mv.Membership.Setting
|
||||||
|> Ash.Query.filter(default_membership_fee_type_id == ^changeset.data.id)
|
|> Ash.Query.filter(default_membership_fee_type_id == ^changeset.data.id)
|
||||||
|> Ash.count!()
|
|> Ash.count!(authorize?: false)
|
||||||
|
|
||||||
if setting_count > 0 do
|
if setting_count > 0 do
|
||||||
{:error,
|
{:error,
|
||||||
|
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
defmodule Mv.Authorization.Checks.NoActor do
|
|
||||||
@moduledoc """
|
|
||||||
Custom Ash Policy Check that allows actions when no actor is present.
|
|
||||||
|
|
||||||
**IMPORTANT:** This check ONLY works in test environment for security reasons.
|
|
||||||
In production/dev, ALL operations without an actor are denied.
|
|
||||||
|
|
||||||
## Security Note
|
|
||||||
|
|
||||||
This check uses compile-time environment detection to prevent accidental
|
|
||||||
security issues in production. In production, ALL operations (including :create
|
|
||||||
and :read) will be denied if no actor is present.
|
|
||||||
|
|
||||||
For seeds and system operations in production, use an admin actor instead:
|
|
||||||
|
|
||||||
admin_user = get_admin_user()
|
|
||||||
Ash.create!(resource, attrs, actor: admin_user)
|
|
||||||
|
|
||||||
## Usage in Policies
|
|
||||||
|
|
||||||
policies do
|
|
||||||
# Allow system operations without actor (TEST ENVIRONMENT ONLY)
|
|
||||||
# In test: All operations allowed
|
|
||||||
# In production: ALL operations denied (fail-closed)
|
|
||||||
bypass action_type([:create, :read, :update, :destroy]) do
|
|
||||||
authorize_if NoActor
|
|
||||||
end
|
|
||||||
|
|
||||||
# Check permissions when actor is present
|
|
||||||
policy action_type([:read, :create, :update, :destroy]) do
|
|
||||||
authorize_if HasPermission
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
## Behavior
|
|
||||||
|
|
||||||
- In test environment: Returns `true` when actor is nil (allows all operations)
|
|
||||||
- In production/dev: Returns `false` when actor is nil (denies all operations - fail-closed)
|
|
||||||
- Returns `false` when actor is present (delegates to other policies)
|
|
||||||
"""
|
|
||||||
|
|
||||||
use Ash.Policy.SimpleCheck
|
|
||||||
|
|
||||||
# Compile-time check: Only allow no-actor bypass in test environment
|
|
||||||
# SECURITY: This must ONLY be true in test.exs, never in prod/dev
|
|
||||||
# Using compile_env instead of Mix.env() for release-safety
|
|
||||||
@allow_no_actor_bypass Application.compile_env(:mv, :allow_no_actor_bypass, false)
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def describe(_opts) do
|
|
||||||
if @allow_no_actor_bypass do
|
|
||||||
"allows actions when no actor is present (test environment only)"
|
|
||||||
else
|
|
||||||
"denies all actions when no actor is present (production/dev - fail-closed)"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def match?(nil, _context, _opts) do
|
|
||||||
# Actor is nil
|
|
||||||
# SECURITY: Only allow if compile_env flag is set (test.exs only)
|
|
||||||
# No runtime Mix.env() check - fail-closed by default (false)
|
|
||||||
@allow_no_actor_bypass
|
|
||||||
end
|
|
||||||
|
|
||||||
def match?(_actor, _context, _opts) do
|
|
||||||
# Actor is present - don't match (let other policies decide)
|
|
||||||
false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -271,11 +271,12 @@ defmodule Mv.Helpers.SystemActor do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Finds admin role in existing roles
|
# 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}
|
@spec find_admin_role() :: {:ok, Mv.Authorization.Role.t()} | {:error, :not_found}
|
||||||
defp find_admin_role do
|
defp find_admin_role do
|
||||||
alias Mv.Authorization
|
alias Mv.Authorization
|
||||||
|
|
||||||
case Authorization.list_roles() do
|
case Authorization.list_roles(authorize?: false) do
|
||||||
{:ok, roles} ->
|
{:ok, roles} ->
|
||||||
case Enum.find(roles, &(&1.permission_set_name == "admin")) do
|
case Enum.find(roles, &(&1.permission_set_name == "admin")) do
|
||||||
nil -> {:error, :not_found}
|
nil -> {:error, :not_found}
|
||||||
|
|
@ -305,16 +306,20 @@ defmodule Mv.Helpers.SystemActor do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Attempts to create admin role
|
# Attempts to create admin role
|
||||||
|
# SECURITY: Uses authorize?: false for bootstrap role creation.
|
||||||
@spec create_admin_role() ::
|
@spec create_admin_role() ::
|
||||||
{:ok, Mv.Authorization.Role.t()} | {:error, :already_exists | term()}
|
{:ok, Mv.Authorization.Role.t()} | {:error, :already_exists | term()}
|
||||||
defp create_admin_role do
|
defp create_admin_role do
|
||||||
alias Mv.Authorization
|
alias Mv.Authorization
|
||||||
|
|
||||||
case Authorization.create_role(%{
|
case Authorization.create_role(
|
||||||
name: "Admin",
|
%{
|
||||||
description: "Administrator with full access",
|
name: "Admin",
|
||||||
permission_set_name: "admin"
|
description: "Administrator with full access",
|
||||||
}) do
|
permission_set_name: "admin"
|
||||||
|
},
|
||||||
|
authorize?: false
|
||||||
|
) do
|
||||||
{:ok, role} ->
|
{:ok, role} ->
|
||||||
{:ok, role}
|
{:ok, role}
|
||||||
|
|
||||||
|
|
@ -327,11 +332,12 @@ defmodule Mv.Helpers.SystemActor do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Finds existing admin role after creation attempt failed due to race condition
|
# 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()
|
@spec find_existing_admin_role() :: Mv.Authorization.Role.t() | no_return()
|
||||||
defp find_existing_admin_role do
|
defp find_existing_admin_role do
|
||||||
alias Mv.Authorization
|
alias Mv.Authorization
|
||||||
|
|
||||||
case Authorization.list_roles() do
|
case Authorization.list_roles(authorize?: false) do
|
||||||
{:ok, roles} ->
|
{:ok, roles} ->
|
||||||
Enum.find(roles, &(&1.permission_set_name == "admin")) ||
|
Enum.find(roles, &(&1.permission_set_name == "admin")) ||
|
||||||
raise "Admin role should exist but was not found"
|
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
|
defp create_system_user_with_role(admin_role) do
|
||||||
alias Mv.Accounts
|
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()},
|
Accounts.create_user!(%{email: system_user_email_config()},
|
||||||
upsert?: true,
|
upsert?: true,
|
||||||
upsert_identity: :unique_email
|
upsert_identity: :unique_email,
|
||||||
|
authorize?: false
|
||||||
)
|
)
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|> Ash.update!()
|
|> Ash.update!(authorize?: false)
|
||||||
|> Ash.load!(:role, domain: Mv.Accounts)
|
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Finds a user by email address
|
# Finds a user by email address
|
||||||
|
|
@ -376,9 +390,12 @@ defmodule Mv.Helpers.SystemActor do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Loads a user with their role preloaded (required for authorization)
|
# 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()
|
@spec load_user_with_role(Mv.Accounts.User.t()) :: Mv.Accounts.User.t() | no_return()
|
||||||
defp load_user_with_role(user) do
|
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} ->
|
{:ok, user_with_role} ->
|
||||||
validate_admin_role(user_with_role)
|
validate_admin_role(user_with_role)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -512,7 +512,10 @@ defmodule Mv.Membership.Import.MemberCSV do
|
||||||
member_attrs_with_cf
|
member_attrs_with_cf
|
||||||
end
|
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} ->
|
||||||
{:ok, member}
|
{:ok, member}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,11 @@ defmodule Mv.Accounts.EmailSyncEdgeCasesTest do
|
||||||
alias Mv.Accounts
|
alias Mv.Accounts
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
|
||||||
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
%{actor: system_actor}
|
||||||
|
end
|
||||||
|
|
||||||
describe "Email sync edge cases" do
|
describe "Email sync edge cases" do
|
||||||
@valid_user_attrs %{
|
@valid_user_attrs %{
|
||||||
email: "user@example.com"
|
email: "user@example.com"
|
||||||
|
|
@ -18,15 +23,15 @@ defmodule Mv.Accounts.EmailSyncEdgeCasesTest do
|
||||||
email: "member@example.com"
|
email: "member@example.com"
|
||||||
}
|
}
|
||||||
|
|
||||||
test "simultaneous email updates use user email as source of truth" do
|
test "simultaneous email updates use user email as source of truth", %{actor: actor} do
|
||||||
# Create linked user and member
|
# Create linked user and member
|
||||||
{:ok, member} = Membership.create_member(@valid_member_attrs)
|
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
|
||||||
|
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
|
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}), actor: actor)
|
||||||
|
|
||||||
# Verify link and initial sync
|
# Verify link and initial sync
|
||||||
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
|
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
|
||||||
assert synced_member.email == "user@example.com"
|
assert synced_member.email == "user@example.com"
|
||||||
|
|
||||||
# Scenario: Both emails are updated "simultaneously"
|
# Scenario: Both emails are updated "simultaneously"
|
||||||
|
|
@ -35,58 +40,60 @@ defmodule Mv.Accounts.EmailSyncEdgeCasesTest do
|
||||||
|
|
||||||
# Update member email first
|
# Update member email first
|
||||||
{:ok, _updated_member} =
|
{:ok, _updated_member} =
|
||||||
Membership.update_member(member, %{email: "member-new@example.com"})
|
Membership.update_member(member, %{email: "member-new@example.com"}, actor: actor)
|
||||||
|
|
||||||
# Verify it synced to user
|
# Verify it synced to user
|
||||||
{:ok, user_after_member_update} = Ash.get(Mv.Accounts.User, user.id)
|
{:ok, user_after_member_update} = Ash.get(Mv.Accounts.User, user.id, actor: actor)
|
||||||
assert to_string(user_after_member_update.email) == "member-new@example.com"
|
assert to_string(user_after_member_update.email) == "member-new@example.com"
|
||||||
|
|
||||||
# Now update user email - this should override
|
# Now update user email - this should override
|
||||||
{:ok, _updated_user} =
|
{:ok, _updated_user} =
|
||||||
Accounts.update_user(user_after_member_update, %{email: "user-final@example.com"})
|
Accounts.update_user(user_after_member_update, %{email: "user-final@example.com"},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Reload both
|
# Reload both
|
||||||
{:ok, final_user} = Ash.get(Mv.Accounts.User, user.id)
|
{:ok, final_user} = Ash.get(Mv.Accounts.User, user.id, actor: actor)
|
||||||
{:ok, final_member} = Ash.get(Mv.Membership.Member, member.id)
|
{:ok, final_member} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
|
||||||
|
|
||||||
# User email should be the final truth
|
# User email should be the final truth
|
||||||
assert to_string(final_user.email) == "user-final@example.com"
|
assert to_string(final_user.email) == "user-final@example.com"
|
||||||
assert final_member.email == "user-final@example.com"
|
assert final_member.email == "user-final@example.com"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "email validation works for both user and member" do
|
test "email validation works for both user and member", %{actor: actor} do
|
||||||
# Test that invalid emails are rejected for both resources
|
# Test that invalid emails are rejected for both resources
|
||||||
|
|
||||||
# Invalid email for user
|
# Invalid email for user
|
||||||
invalid_user_result = Accounts.create_user(%{email: "not-an-email"})
|
invalid_user_result = Accounts.create_user(%{email: "not-an-email"}, actor: actor)
|
||||||
assert {:error, %Ash.Error.Invalid{}} = invalid_user_result
|
assert {:error, %Ash.Error.Invalid{}} = invalid_user_result
|
||||||
|
|
||||||
# Invalid email for member
|
# Invalid email for member
|
||||||
invalid_member_attrs = Map.put(@valid_member_attrs, :email, "also-not-an-email")
|
invalid_member_attrs = Map.put(@valid_member_attrs, :email, "also-not-an-email")
|
||||||
invalid_member_result = Membership.create_member(invalid_member_attrs)
|
invalid_member_result = Membership.create_member(invalid_member_attrs, actor: actor)
|
||||||
assert {:error, %Ash.Error.Invalid{}} = invalid_member_result
|
assert {:error, %Ash.Error.Invalid{}} = invalid_member_result
|
||||||
|
|
||||||
# Valid emails should work
|
# Valid emails should work
|
||||||
{:ok, _user} = Accounts.create_user(@valid_user_attrs)
|
{:ok, _user} = Accounts.create_user(@valid_user_attrs, actor: actor)
|
||||||
{:ok, _member} = Membership.create_member(@valid_member_attrs)
|
{:ok, _member} = Membership.create_member(@valid_member_attrs, actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "identity constraints prevent duplicate emails" do
|
test "identity constraints prevent duplicate emails", %{actor: actor} do
|
||||||
# Create first user with an email
|
# Create first user with an email
|
||||||
{:ok, user1} = Accounts.create_user(%{email: "duplicate@example.com"})
|
{:ok, user1} = Accounts.create_user(%{email: "duplicate@example.com"}, actor: actor)
|
||||||
assert to_string(user1.email) == "duplicate@example.com"
|
assert to_string(user1.email) == "duplicate@example.com"
|
||||||
|
|
||||||
# Try to create second user with same email - should fail due to unique constraint
|
# Try to create second user with same email - should fail due to unique constraint
|
||||||
result = Accounts.create_user(%{email: "duplicate@example.com"})
|
result = Accounts.create_user(%{email: "duplicate@example.com"}, actor: actor)
|
||||||
assert {:error, %Ash.Error.Invalid{}} = result
|
assert {:error, %Ash.Error.Invalid{}} = result
|
||||||
|
|
||||||
# Same for members
|
# Same for members
|
||||||
member_attrs = Map.put(@valid_member_attrs, :email, "member-dup@example.com")
|
member_attrs = Map.put(@valid_member_attrs, :email, "member-dup@example.com")
|
||||||
{:ok, member1} = Membership.create_member(member_attrs)
|
{:ok, member1} = Membership.create_member(member_attrs, actor: actor)
|
||||||
assert member1.email == "member-dup@example.com"
|
assert member1.email == "member-dup@example.com"
|
||||||
|
|
||||||
# Try to create second member with same email - should fail
|
# Try to create second member with same email - should fail
|
||||||
result2 = Membership.create_member(member_attrs)
|
result2 = Membership.create_member(member_attrs, actor: actor)
|
||||||
assert {:error, %Ash.Error.Invalid{}} = result2
|
assert {:error, %Ash.Error.Invalid{}} = result2
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -4,121 +4,177 @@ defmodule Mv.Accounts.EmailUniquenessTest do
|
||||||
alias Mv.Accounts
|
alias Mv.Accounts
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
|
||||||
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
%{actor: system_actor}
|
||||||
|
end
|
||||||
|
|
||||||
describe "Email uniqueness validation - Creation" do
|
describe "Email uniqueness validation - Creation" do
|
||||||
test "CAN create member with existing unlinked user email" do
|
test "CAN create member with existing unlinked user email", %{actor: actor} do
|
||||||
# Create a user with email
|
# Create a user with email
|
||||||
{:ok, _user} =
|
{:ok, _user} =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "existing@example.com"
|
%{
|
||||||
})
|
email: "existing@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Create member with same email - should succeed
|
# Create member with same email - should succeed
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "John",
|
%{
|
||||||
last_name: "Doe",
|
first_name: "John",
|
||||||
email: "existing@example.com"
|
last_name: "Doe",
|
||||||
})
|
email: "existing@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
assert to_string(member.email) == "existing@example.com"
|
assert to_string(member.email) == "existing@example.com"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "CAN create user with existing unlinked member email" do
|
test "CAN create user with existing unlinked member email", %{actor: actor} do
|
||||||
# Create a member with email
|
# Create a member with email
|
||||||
{:ok, _member} =
|
{:ok, _member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "John",
|
%{
|
||||||
last_name: "Doe",
|
first_name: "John",
|
||||||
email: "existing@example.com"
|
last_name: "Doe",
|
||||||
})
|
email: "existing@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Create user with same email - should succeed
|
# Create user with same email - should succeed
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "existing@example.com"
|
%{
|
||||||
})
|
email: "existing@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
assert to_string(user.email) == "existing@example.com"
|
assert to_string(user.email) == "existing@example.com"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "Email uniqueness validation - Updating unlinked entities" do
|
describe "Email uniqueness validation - Updating unlinked entities" do
|
||||||
test "unlinked member email CAN be changed to an existing unlinked user email" do
|
test "unlinked member email CAN be changed to an existing unlinked user email", %{
|
||||||
|
actor: actor
|
||||||
|
} do
|
||||||
# Create a user with email
|
# Create a user with email
|
||||||
{:ok, _user} =
|
{:ok, _user} =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "existing_user@example.com"
|
%{
|
||||||
})
|
email: "existing_user@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Create an unlinked member with different email
|
# Create an unlinked member with different email
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "John",
|
%{
|
||||||
last_name: "Doe",
|
first_name: "John",
|
||||||
email: "member@example.com"
|
last_name: "Doe",
|
||||||
})
|
email: "member@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Change member email to existing user email - should succeed (member is unlinked)
|
# Change member email to existing user email - should succeed (member is unlinked)
|
||||||
{:ok, updated_member} =
|
{:ok, updated_member} =
|
||||||
Membership.update_member(member, %{
|
Membership.update_member(
|
||||||
email: "existing_user@example.com"
|
member,
|
||||||
})
|
%{
|
||||||
|
email: "existing_user@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
assert to_string(updated_member.email) == "existing_user@example.com"
|
assert to_string(updated_member.email) == "existing_user@example.com"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "unlinked user email CAN be changed to an existing unlinked member email" do
|
test "unlinked user email CAN be changed to an existing unlinked member email", %{
|
||||||
|
actor: actor
|
||||||
|
} do
|
||||||
# Create a member with email
|
# Create a member with email
|
||||||
{:ok, _member} =
|
{:ok, _member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "John",
|
%{
|
||||||
last_name: "Doe",
|
first_name: "John",
|
||||||
email: "existing_member@example.com"
|
last_name: "Doe",
|
||||||
})
|
email: "existing_member@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Create an unlinked user with different email
|
# Create an unlinked user with different email
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "user@example.com"
|
%{
|
||||||
})
|
email: "user@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Change user email to existing member email - should succeed (user is unlinked)
|
# Change user email to existing member email - should succeed (user is unlinked)
|
||||||
{:ok, updated_user} =
|
{:ok, updated_user} =
|
||||||
Accounts.update_user(user, %{
|
Accounts.update_user(
|
||||||
email: "existing_member@example.com"
|
user,
|
||||||
})
|
%{
|
||||||
|
email: "existing_member@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
assert to_string(updated_user.email) == "existing_member@example.com"
|
assert to_string(updated_user.email) == "existing_member@example.com"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "unlinked member email CANNOT be changed to an existing linked user email" do
|
test "unlinked member email CANNOT be changed to an existing linked user email", %{
|
||||||
|
actor: actor
|
||||||
|
} do
|
||||||
# Create a user and link it to a member - this makes the user "linked"
|
# Create a user and link it to a member - this makes the user "linked"
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "linked_user@example.com"
|
%{
|
||||||
})
|
email: "linked_user@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, _member_a} =
|
{:ok, _member_a} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Member",
|
%{
|
||||||
last_name: "A",
|
first_name: "Member",
|
||||||
email: "temp@example.com",
|
last_name: "A",
|
||||||
user: %{id: user.id}
|
email: "temp@example.com",
|
||||||
})
|
user: %{id: user.id}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Create an unlinked member with different email
|
# Create an unlinked member with different email
|
||||||
{:ok, member_b} =
|
{:ok, member_b} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Member",
|
%{
|
||||||
last_name: "B",
|
first_name: "Member",
|
||||||
email: "member_b@example.com"
|
last_name: "B",
|
||||||
})
|
email: "member_b@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Try to change unlinked member's email to linked user's email - should fail
|
# Try to change unlinked member's email to linked user's email - should fail
|
||||||
result =
|
result =
|
||||||
Membership.update_member(member_b, %{
|
Membership.update_member(
|
||||||
email: "linked_user@example.com"
|
member_b,
|
||||||
})
|
%{
|
||||||
|
email: "linked_user@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
assert {:error, %Ash.Error.Invalid{} = error} = result
|
assert {:error, %Ash.Error.Invalid{} = error} = result
|
||||||
|
|
||||||
|
|
@ -129,37 +185,52 @@ defmodule Mv.Accounts.EmailUniquenessTest do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "unlinked user email CANNOT be changed to an existing linked member email" do
|
test "unlinked user email CANNOT be changed to an existing linked member email", %{
|
||||||
|
actor: actor
|
||||||
|
} do
|
||||||
# Create a user and link it to a member - this makes the member "linked"
|
# Create a user and link it to a member - this makes the member "linked"
|
||||||
{:ok, user_a} =
|
{:ok, user_a} =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "user_a@example.com"
|
%{
|
||||||
})
|
email: "user_a@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, _member_a} =
|
{:ok, _member_a} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Member",
|
%{
|
||||||
last_name: "A",
|
first_name: "Member",
|
||||||
email: "temp@example.com",
|
last_name: "A",
|
||||||
user: %{id: user_a.id}
|
email: "temp@example.com",
|
||||||
})
|
user: %{id: user_a.id}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Reload user to get updated member_id and linked member email
|
# Reload user to get updated member_id and linked member email
|
||||||
{:ok, user_a_reloaded} = Ash.get(Mv.Accounts.User, user_a.id)
|
{:ok, user_a_reloaded} = Ash.get(Mv.Accounts.User, user_a.id, actor: actor)
|
||||||
{:ok, user_a_with_member} = Ash.load(user_a_reloaded, :member)
|
{:ok, user_a_with_member} = Ash.load(user_a_reloaded, :member, actor: actor)
|
||||||
linked_member_email = to_string(user_a_with_member.member.email)
|
linked_member_email = to_string(user_a_with_member.member.email)
|
||||||
|
|
||||||
# Create an unlinked user with different email
|
# Create an unlinked user with different email
|
||||||
{:ok, user_b} =
|
{:ok, user_b} =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "user_b@example.com"
|
%{
|
||||||
})
|
email: "user_b@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Try to change unlinked user's email to linked member's email - should fail
|
# Try to change unlinked user's email to linked member's email - should fail
|
||||||
result =
|
result =
|
||||||
Accounts.update_user(user_b, %{
|
Accounts.update_user(
|
||||||
email: linked_member_email
|
user_b,
|
||||||
})
|
%{
|
||||||
|
email: linked_member_email
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
assert {:error, %Ash.Error.Invalid{} = error} = result
|
assert {:error, %Ash.Error.Invalid{} = error} = result
|
||||||
|
|
||||||
|
|
@ -172,28 +243,37 @@ defmodule Mv.Accounts.EmailUniquenessTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "Email uniqueness validation - Creating with linked emails" do
|
describe "Email uniqueness validation - Creating with linked emails" do
|
||||||
test "CANNOT create member with existing linked user email" do
|
test "CANNOT create member with existing linked user email", %{actor: actor} do
|
||||||
# Create a user and link it to a member
|
# Create a user and link it to a member
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "linked@example.com"
|
%{
|
||||||
})
|
email: "linked@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, _member} =
|
{:ok, _member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "First",
|
%{
|
||||||
last_name: "Member",
|
first_name: "First",
|
||||||
email: "temp@example.com",
|
last_name: "Member",
|
||||||
user: %{id: user.id}
|
email: "temp@example.com",
|
||||||
})
|
user: %{id: user.id}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Try to create a new member with the linked user's email - should fail
|
# Try to create a new member with the linked user's email - should fail
|
||||||
result =
|
result =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Second",
|
%{
|
||||||
last_name: "Member",
|
first_name: "Second",
|
||||||
email: "linked@example.com"
|
last_name: "Member",
|
||||||
})
|
email: "linked@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
assert {:error, %Ash.Error.Invalid{} = error} = result
|
assert {:error, %Ash.Error.Invalid{} = error} = result
|
||||||
|
|
||||||
|
|
@ -204,31 +284,40 @@ defmodule Mv.Accounts.EmailUniquenessTest do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "CANNOT create user with existing linked member email" do
|
test "CANNOT create user with existing linked member email", %{actor: actor} do
|
||||||
# Create a user and link it to a member
|
# Create a user and link it to a member
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "user@example.com"
|
%{
|
||||||
})
|
email: "user@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, _member} =
|
{:ok, _member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Member",
|
%{
|
||||||
last_name: "One",
|
first_name: "Member",
|
||||||
email: "temp@example.com",
|
last_name: "One",
|
||||||
user: %{id: user.id}
|
email: "temp@example.com",
|
||||||
})
|
user: %{id: user.id}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Reload user to get the linked member's email
|
# Reload user to get the linked member's email
|
||||||
{:ok, user_reloaded} = Ash.get(Mv.Accounts.User, user.id)
|
{:ok, user_reloaded} = Ash.get(Mv.Accounts.User, user.id, actor: actor)
|
||||||
{:ok, user_with_member} = Ash.load(user_reloaded, :member)
|
{:ok, user_with_member} = Ash.load(user_reloaded, :member, actor: actor)
|
||||||
linked_member_email = to_string(user_with_member.member.email)
|
linked_member_email = to_string(user_with_member.member.email)
|
||||||
|
|
||||||
# Try to create a new user with the linked member's email - should fail
|
# Try to create a new user with the linked member's email - should fail
|
||||||
result =
|
result =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: linked_member_email
|
%{
|
||||||
})
|
email: linked_member_email
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
assert {:error, %Ash.Error.Invalid{} = error} = result
|
assert {:error, %Ash.Error.Invalid{} = error} = result
|
||||||
|
|
||||||
|
|
@ -241,32 +330,45 @@ defmodule Mv.Accounts.EmailUniquenessTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "Email uniqueness validation - Updating linked entities" do
|
describe "Email uniqueness validation - Updating linked entities" do
|
||||||
test "linked member email CANNOT be changed to an existing user email" do
|
test "linked member email CANNOT be changed to an existing user email", %{actor: actor} do
|
||||||
# Create a user with email
|
# Create a user with email
|
||||||
{:ok, _other_user} =
|
{:ok, _other_user} =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "other_user@example.com"
|
%{
|
||||||
})
|
email: "other_user@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Create a user and link it to a member
|
# Create a user and link it to a member
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "user@example.com"
|
%{
|
||||||
})
|
email: "user@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "John",
|
%{
|
||||||
last_name: "Doe",
|
first_name: "John",
|
||||||
email: "temp@example.com",
|
last_name: "Doe",
|
||||||
user: %{id: user.id}
|
email: "temp@example.com",
|
||||||
})
|
user: %{id: user.id}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Try to change linked member's email to other user's email - should fail
|
# Try to change linked member's email to other user's email - should fail
|
||||||
result =
|
result =
|
||||||
Membership.update_member(member, %{
|
Membership.update_member(
|
||||||
email: "other_user@example.com"
|
member,
|
||||||
})
|
%{
|
||||||
|
email: "other_user@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
assert {:error, %Ash.Error.Invalid{} = error} = result
|
assert {:error, %Ash.Error.Invalid{} = error} = result
|
||||||
|
|
||||||
|
|
@ -277,37 +379,50 @@ defmodule Mv.Accounts.EmailUniquenessTest do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "linked user email CANNOT be changed to an existing member email" do
|
test "linked user email CANNOT be changed to an existing member email", %{actor: actor} do
|
||||||
# Create a member with email
|
# Create a member with email
|
||||||
{:ok, _other_member} =
|
{:ok, _other_member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Jane",
|
%{
|
||||||
last_name: "Doe",
|
first_name: "Jane",
|
||||||
email: "other_member@example.com"
|
last_name: "Doe",
|
||||||
})
|
email: "other_member@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Create a user and link it to a member
|
# Create a user and link it to a member
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "user@example.com"
|
%{
|
||||||
})
|
email: "user@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, _member} =
|
{:ok, _member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "John",
|
%{
|
||||||
last_name: "Doe",
|
first_name: "John",
|
||||||
email: "temp@example.com",
|
last_name: "Doe",
|
||||||
user: %{id: user.id}
|
email: "temp@example.com",
|
||||||
})
|
user: %{id: user.id}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Reload user to get updated member_id
|
# Reload user to get updated member_id
|
||||||
{:ok, user_reloaded} = Ash.get(Mv.Accounts.User, user.id)
|
{:ok, user_reloaded} = Ash.get(Mv.Accounts.User, user.id, actor: actor)
|
||||||
|
|
||||||
# Try to change linked user's email to other member's email - should fail
|
# Try to change linked user's email to other member's email - should fail
|
||||||
result =
|
result =
|
||||||
Accounts.update_user(user_reloaded, %{
|
Accounts.update_user(
|
||||||
email: "other_member@example.com"
|
user_reloaded,
|
||||||
})
|
%{
|
||||||
|
email: "other_member@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
assert {:error, %Ash.Error.Invalid{} = error} = result
|
assert {:error, %Ash.Error.Invalid{} = error} = result
|
||||||
|
|
||||||
|
|
@ -320,34 +435,49 @@ defmodule Mv.Accounts.EmailUniquenessTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "Email uniqueness validation - Linking" do
|
describe "Email uniqueness validation - Linking" do
|
||||||
test "CANNOT link user to member if user email is already used by another unlinked member" do
|
test "CANNOT link user to member if user email is already used by another unlinked member", %{
|
||||||
|
actor: actor
|
||||||
|
} do
|
||||||
# Create a member with email
|
# Create a member with email
|
||||||
{:ok, _other_member} =
|
{:ok, _other_member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Jane",
|
%{
|
||||||
last_name: "Doe",
|
first_name: "Jane",
|
||||||
email: "duplicate@example.com"
|
last_name: "Doe",
|
||||||
})
|
email: "duplicate@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Create a user with same email
|
# Create a user with same email
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "duplicate@example.com"
|
%{
|
||||||
})
|
email: "duplicate@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Create a member to link with the user
|
# Create a member to link with the user
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "John",
|
%{
|
||||||
last_name: "Smith",
|
first_name: "John",
|
||||||
email: "john@example.com"
|
last_name: "Smith",
|
||||||
})
|
email: "john@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Try to link user to member - should fail because user.email is already used by other_member
|
# Try to link user to member - should fail because user.email is already used by other_member
|
||||||
result =
|
result =
|
||||||
Accounts.update_user(user, %{
|
Accounts.update_user(
|
||||||
member: %{id: member.id}
|
user,
|
||||||
})
|
%{
|
||||||
|
member: %{id: member.id}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
assert {:error, %Ash.Error.Invalid{} = error} = result
|
assert {:error, %Ash.Error.Invalid{} = error} = result
|
||||||
|
|
||||||
|
|
@ -358,120 +488,160 @@ defmodule Mv.Accounts.EmailUniquenessTest do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "CAN link member to user even if member email is used by another user (member email gets overridden)" do
|
test "CAN link member to user even if member email is used by another user (member email gets overridden)",
|
||||||
|
%{actor: actor} do
|
||||||
# Create a user with email
|
# Create a user with email
|
||||||
{:ok, _other_user} =
|
{:ok, _other_user} =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "duplicate@example.com"
|
%{
|
||||||
})
|
email: "duplicate@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Create a member with same email
|
# Create a member with same email
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "John",
|
%{
|
||||||
last_name: "Doe",
|
first_name: "John",
|
||||||
email: "duplicate@example.com"
|
last_name: "Doe",
|
||||||
})
|
email: "duplicate@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Create a user to link with the member
|
# Create a user to link with the member
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "user@example.com"
|
%{
|
||||||
})
|
email: "user@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Link member to user - should succeed because member.email will be overridden
|
# Link member to user - should succeed because member.email will be overridden
|
||||||
{:ok, updated_member} =
|
{:ok, updated_member} =
|
||||||
Membership.update_member(member, %{
|
Membership.update_member(
|
||||||
user: %{id: user.id}
|
member,
|
||||||
})
|
%{
|
||||||
|
user: %{id: user.id}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Member email should now be the same as user email
|
# Member email should now be the same as user email
|
||||||
{:ok, member_reloaded} = Ash.get(Mv.Membership.Member, updated_member.id)
|
{:ok, member_reloaded} = Ash.get(Mv.Membership.Member, updated_member.id, actor: actor)
|
||||||
assert to_string(member_reloaded.email) == "user@example.com"
|
assert to_string(member_reloaded.email) == "user@example.com"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "Email syncing" do
|
describe "Email syncing" do
|
||||||
test "member email syncs to linked user email without validation error" do
|
test "member email syncs to linked user email without validation error", %{actor: actor} do
|
||||||
# Create a user
|
# Create a user
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "user@example.com"
|
%{
|
||||||
})
|
email: "user@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Create a member linked to this user
|
# Create a member linked to this user
|
||||||
# The override change will set member.email = user.email automatically
|
# The override change will set member.email = user.email automatically
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "John",
|
%{
|
||||||
last_name: "Doe",
|
first_name: "John",
|
||||||
email: "member@example.com",
|
last_name: "Doe",
|
||||||
user: %{id: user.id}
|
email: "member@example.com",
|
||||||
})
|
user: %{id: user.id}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Member email should have been overridden to user email
|
# Member email should have been overridden to user email
|
||||||
# This happens through our sync mechanism, which should NOT trigger
|
# This happens through our sync mechanism, which should NOT trigger
|
||||||
# the "email already used" validation because it's the same user
|
# the "email already used" validation because it's the same user
|
||||||
{:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id)
|
{:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
|
||||||
assert member_after_link.email == "user@example.com"
|
assert member_after_link.email == "user@example.com"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "user email syncs to linked member without validation error" do
|
test "user email syncs to linked member without validation error", %{actor: actor} do
|
||||||
# Create a member
|
# Create a member
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "John",
|
%{
|
||||||
last_name: "Doe",
|
first_name: "John",
|
||||||
email: "member@example.com"
|
last_name: "Doe",
|
||||||
})
|
email: "member@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Create a user linked to this member
|
# Create a user linked to this member
|
||||||
# The override change will set member.email = user.email automatically
|
# The override change will set member.email = user.email automatically
|
||||||
{:ok, _user} =
|
{:ok, _user} =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "user@example.com",
|
%{
|
||||||
member: %{id: member.id}
|
email: "user@example.com",
|
||||||
})
|
member: %{id: member.id}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Member email should have been overridden to user email
|
# Member email should have been overridden to user email
|
||||||
# This happens through our sync mechanism, which should NOT trigger
|
# This happens through our sync mechanism, which should NOT trigger
|
||||||
# the "email already used" validation because it's the same member
|
# the "email already used" validation because it's the same member
|
||||||
{:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id)
|
{:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
|
||||||
assert member_after_link.email == "user@example.com"
|
assert member_after_link.email == "user@example.com"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "two unlinked users cannot have the same email" do
|
test "two unlinked users cannot have the same email", %{actor: actor} do
|
||||||
# Create first user
|
# Create first user
|
||||||
{:ok, _user1} =
|
{:ok, _user1} =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "duplicate@example.com"
|
%{
|
||||||
})
|
email: "duplicate@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Try to create second user with same email
|
# Try to create second user with same email
|
||||||
result =
|
result =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "duplicate@example.com"
|
%{
|
||||||
})
|
email: "duplicate@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
assert {:error, %Ash.Error.Invalid{}} = result
|
assert {:error, %Ash.Error.Invalid{}} = result
|
||||||
end
|
end
|
||||||
|
|
||||||
test "two unlinked members cannot have the same email (members have unique constraint)" do
|
test "two unlinked members cannot have the same email (members have unique constraint)", %{
|
||||||
|
actor: actor
|
||||||
|
} do
|
||||||
# Create first member
|
# Create first member
|
||||||
{:ok, _member1} =
|
{:ok, _member1} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "John",
|
%{
|
||||||
last_name: "Doe",
|
first_name: "John",
|
||||||
email: "duplicate@example.com"
|
last_name: "Doe",
|
||||||
})
|
email: "duplicate@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Try to create second member with same email - should fail
|
# Try to create second member with same email - should fail
|
||||||
result =
|
result =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Jane",
|
%{
|
||||||
last_name: "Smith",
|
first_name: "Jane",
|
||||||
email: "duplicate@example.com"
|
last_name: "Smith",
|
||||||
})
|
email: "duplicate@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
assert {:error, %Ash.Error.Invalid{}} = result
|
assert {:error, %Ash.Error.Invalid{}} = result
|
||||||
# Members DO have a unique email constraint at database level
|
# Members DO have a unique email constraint at database level
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,11 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
||||||
use MvWeb.ConnCase, async: true
|
use MvWeb.ConnCase, async: true
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
%{actor: system_actor}
|
||||||
|
end
|
||||||
|
|
||||||
describe "Password authentication user identification" do
|
describe "Password authentication user identification" do
|
||||||
@tag :test_proposal
|
@tag :test_proposal
|
||||||
test "password login uses email as identifier" do
|
test "password login uses email as identifier" do
|
||||||
|
|
@ -27,7 +32,7 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
||||||
{:ok, users} =
|
{:ok, users} =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|> Ash.Query.filter(email == ^email_to_find)
|
|> Ash.Query.filter(email == ^email_to_find)
|
||||||
|> Ash.read()
|
|> Ash.read(actor: user)
|
||||||
|
|
||||||
assert length(users) == 1
|
assert length(users) == 1
|
||||||
found_user = List.first(users)
|
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
|
# Use sign_in_with_rauthy to find user by oidc_id
|
||||||
# Note: This test will FAIL until we implement the security fix
|
# Note: This test will FAIL until we implement the security fix
|
||||||
# that changes the filter from email to oidc_id
|
# that changes the filter from email to oidc_id
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Accounts.read_sign_in_with_rauthy(%{
|
Mv.Accounts.read_sign_in_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
case result do
|
case result do
|
||||||
{:ok, [found_user]} ->
|
{:ok, [found_user]} ->
|
||||||
|
|
@ -141,11 +151,16 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
||||||
}
|
}
|
||||||
|
|
||||||
# Should create via register_with_rauthy
|
# Should create via register_with_rauthy
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
{:ok, new_user} =
|
{:ok, new_user} =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
assert to_string(new_user.email) == "newuser@example.com"
|
assert to_string(new_user.email) == "newuser@example.com"
|
||||||
assert new_user.oidc_id == "brand_new_oidc_789"
|
assert new_user.oidc_id == "brand_new_oidc_789"
|
||||||
|
|
@ -170,12 +185,12 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
||||||
{:ok, users1} =
|
{:ok, users1} =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|> Ash.Query.filter(oidc_id == "oidc_unique_1")
|
|> Ash.Query.filter(oidc_id == "oidc_unique_1")
|
||||||
|> Ash.read()
|
|> Ash.read(actor: user1)
|
||||||
|
|
||||||
{:ok, users2} =
|
{:ok, users2} =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|> Ash.Query.filter(oidc_id == "oidc_unique_2")
|
|> Ash.Query.filter(oidc_id == "oidc_unique_2")
|
||||||
|> Ash.read()
|
|> Ash.read(actor: user2)
|
||||||
|
|
||||||
assert length(users1) == 1
|
assert length(users1) == 1
|
||||||
assert length(users2) == 1
|
assert length(users2) == 1
|
||||||
|
|
@ -205,11 +220,16 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
||||||
}
|
}
|
||||||
|
|
||||||
# Should NOT find the user (security requirement)
|
# Should NOT find the user (security requirement)
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Accounts.read_sign_in_with_rauthy(%{
|
Mv.Accounts.read_sign_in_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
# Either returns empty list OR authentication error - both mean "user not found"
|
# Either returns empty list OR authentication error - both mean "user not found"
|
||||||
case result do
|
case result do
|
||||||
|
|
@ -241,11 +261,16 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
||||||
}
|
}
|
||||||
|
|
||||||
# Should NOT find the user because oidc_id is nil
|
# Should NOT find the user because oidc_id is nil
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Accounts.read_sign_in_with_rauthy(%{
|
Mv.Accounts.read_sign_in_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
# Either returns empty list OR authentication error - both mean "user not found"
|
# Either returns empty list OR authentication error - both mean "user not found"
|
||||||
case result do
|
case result do
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,11 @@ defmodule Mv.Accounts.UserEmailSyncTest do
|
||||||
alias Mv.Accounts
|
alias Mv.Accounts
|
||||||
alias Mv.Membership
|
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
|
describe "User email synchronization to linked Member" do
|
||||||
@valid_user_attrs %{
|
@valid_user_attrs %{
|
||||||
email: "user@example.com"
|
email: "user@example.com"
|
||||||
|
|
@ -19,96 +24,100 @@ defmodule Mv.Accounts.UserEmailSyncTest do
|
||||||
email: "member@example.com"
|
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
|
# 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"
|
assert member.email == "member@example.com"
|
||||||
|
|
||||||
# Create a user linked to the member
|
# Create a user linked to the member
|
||||||
{:ok, user} =
|
{: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
|
# 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"
|
assert member_after_link.email == "user@example.com"
|
||||||
|
|
||||||
# Update user email
|
# 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"
|
assert to_string(updated_user.email) == "newemail@example.com"
|
||||||
|
|
||||||
# Verify member email was also updated
|
# 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"
|
assert synced_member.email == "newemail@example.com"
|
||||||
end
|
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
|
# 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"
|
assert member.email == "member@example.com"
|
||||||
|
|
||||||
# Create a user linked to this member
|
# Create a user linked to this member
|
||||||
{:ok, user} =
|
{: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 to_string(user.email) == "user@example.com"
|
||||||
assert user.member_id == member.id
|
assert user.member_id == member.id
|
||||||
|
|
||||||
# Verify member email was overridden with user email
|
# 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"
|
assert updated_member.email == "user@example.com"
|
||||||
end
|
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
|
# 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"
|
assert member.email == "member@example.com"
|
||||||
|
|
||||||
# Create a standalone user
|
# 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 to_string(user.email) == "user@example.com"
|
||||||
assert user.member_id == nil
|
assert user.member_id == nil
|
||||||
|
|
||||||
# Link the user to the member
|
# 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
|
assert linked_user.member_id == member.id
|
||||||
|
|
||||||
# Verify member email was overridden with user email
|
# 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"
|
assert synced_member.email == "user@example.com"
|
||||||
end
|
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
|
# 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 to_string(user.email) == "user@example.com"
|
||||||
assert user.member_id == nil
|
assert user.member_id == nil
|
||||||
|
|
||||||
# Update user email - should work fine without error
|
# 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 to_string(updated_user.email) == "newemail@example.com"
|
||||||
assert updated_user.member_id == nil
|
assert updated_user.member_id == nil
|
||||||
end
|
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
|
# 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
|
# Create user linked to member
|
||||||
{:ok, user} =
|
{: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
|
assert user.member_id == member.id
|
||||||
|
|
||||||
# Verify member email was synced
|
# 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"
|
assert synced_member.email == "user@example.com"
|
||||||
|
|
||||||
# Unlink user from member
|
# 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
|
assert unlinked_user.member_id == nil
|
||||||
|
|
||||||
# Member email should remain unchanged after unlinking
|
# 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"
|
assert member_after_unlink.email == "user@example.com"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -119,6 +128,8 @@ defmodule Mv.Accounts.UserEmailSyncTest do
|
||||||
email = "test@example.com"
|
email = "test@example.com"
|
||||||
password = "securepassword123"
|
password = "securepassword123"
|
||||||
|
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Create user with password strategy (simulating registration)
|
# Create user with password strategy (simulating registration)
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|
|
@ -126,7 +137,7 @@ defmodule Mv.Accounts.UserEmailSyncTest do
|
||||||
email: email,
|
email: email,
|
||||||
password: password
|
password: password
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
assert to_string(user.email) == email
|
assert to_string(user.email) == email
|
||||||
assert user.hashed_password != nil
|
assert user.hashed_password != nil
|
||||||
|
|
@ -138,7 +149,7 @@ defmodule Mv.Accounts.UserEmailSyncTest do
|
||||||
email: email,
|
email: email,
|
||||||
password: password
|
password: password
|
||||||
})
|
})
|
||||||
|> Ash.read_one()
|
|> Ash.read_one(actor: system_actor)
|
||||||
|
|
||||||
assert signed_in_user.id == user.id
|
assert signed_in_user.id == user.id
|
||||||
assert to_string(signed_in_user.email) == email
|
assert to_string(signed_in_user.email) == email
|
||||||
|
|
@ -153,6 +164,8 @@ defmodule Mv.Accounts.UserEmailSyncTest do
|
||||||
|
|
||||||
oauth_tokens = %{"access_token" => "mock_token"}
|
oauth_tokens = %{"access_token" => "mock_token"}
|
||||||
|
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Simulate OIDC registration
|
# Simulate OIDC registration
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|
|
@ -160,7 +173,7 @@ defmodule Mv.Accounts.UserEmailSyncTest do
|
||||||
user_info: user_info,
|
user_info: user_info,
|
||||||
oauth_tokens: oauth_tokens
|
oauth_tokens: oauth_tokens
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
assert to_string(user.email) == "oidc@example.com"
|
assert to_string(user.email) == "oidc@example.com"
|
||||||
assert user.oidc_id == "oidc-user-123"
|
assert user.oidc_id == "oidc-user-123"
|
||||||
|
|
|
||||||
|
|
@ -18,71 +18,86 @@ defmodule Mv.Accounts.UserMemberDeletionTest do
|
||||||
email: "john@example.com"
|
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
|
# 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
|
# Create a user linked to the member
|
||||||
{:ok, user} =
|
{: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
|
# 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
|
||||||
assert user_before_delete.member.id == member.id
|
assert user_before_delete.member.id == member.id
|
||||||
|
|
||||||
# Delete the member
|
# 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
|
# 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.id == user.id
|
||||||
assert user_after_delete.member_id == nil
|
assert user_after_delete.member_id == nil
|
||||||
assert user_after_delete.member == nil
|
assert user_after_delete.member == nil
|
||||||
end
|
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
|
# 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
|
# Create user linked to first member
|
||||||
{:ok, user} =
|
{: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
|
assert user.member_id == member1.id
|
||||||
|
|
||||||
# Delete first member
|
# 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)
|
# 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
|
assert user_after_delete.member_id == nil
|
||||||
|
|
||||||
# Create second member
|
# Create second member
|
||||||
{:ok, member2} =
|
{:ok, member2} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Jane",
|
%{
|
||||||
last_name: "Smith",
|
first_name: "Jane",
|
||||||
email: "jane@example.com"
|
last_name: "Smith",
|
||||||
})
|
email: "jane@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Link user to second member (use reloaded user)
|
# 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
|
# 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
|
||||||
assert final_user.member.id == member2.id
|
assert final_user.member.id == member2.id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "member without linked user can be deleted normally" do
|
test "member without linked user can be deleted normally", %{actor: actor} do
|
||||||
{:ok, member} = Membership.create_member(@valid_member_attrs)
|
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
|
||||||
|
|
||||||
# Delete member (no users linked)
|
# Delete member (no users linked)
|
||||||
assert :ok = Membership.destroy_member(member)
|
assert :ok = Membership.destroy_member(member, actor: actor)
|
||||||
|
|
||||||
# Verify member is deleted
|
# 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
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -10,51 +10,70 @@ defmodule Mv.Accounts.UserMemberLinkingEmailTest do
|
||||||
alias Mv.Accounts
|
alias Mv.Accounts
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
|
||||||
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
%{actor: system_actor}
|
||||||
|
end
|
||||||
|
|
||||||
describe "link with same email" do
|
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
|
# Create member with specific email
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Alice",
|
%{
|
||||||
last_name: "Johnson",
|
first_name: "Alice",
|
||||||
email: "alice@example.com"
|
last_name: "Johnson",
|
||||||
})
|
email: "alice@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Create user with same email and link to member
|
# Create user with same email and link to member
|
||||||
result =
|
result =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "alice@example.com",
|
%{
|
||||||
member: %{id: member.id}
|
email: "alice@example.com",
|
||||||
})
|
member: %{id: member.id}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Should succeed without errors
|
# Should succeed without errors
|
||||||
assert {:ok, user} = result
|
assert {:ok, user} = result
|
||||||
assert to_string(user.email) == "alice@example.com"
|
assert to_string(user.email) == "alice@example.com"
|
||||||
|
|
||||||
# Reload to verify link
|
# 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.id == member.id
|
||||||
assert user.member.email == "alice@example.com"
|
assert user.member.email == "alice@example.com"
|
||||||
end
|
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
|
# Create member
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Bob",
|
%{
|
||||||
last_name: "Smith",
|
first_name: "Bob",
|
||||||
email: "bob@example.com"
|
last_name: "Smith",
|
||||||
})
|
email: "bob@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Create user and link
|
# Create user and link
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "bob@example.com",
|
%{
|
||||||
member: %{id: member.id}
|
email: "bob@example.com",
|
||||||
})
|
member: %{id: member.id}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Update user (should not trigger email validation error)
|
# 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 {:ok, updated_user} = result
|
||||||
assert to_string(updated_user.email) == "bob@example.com"
|
assert to_string(updated_user.email) == "bob@example.com"
|
||||||
|
|
@ -62,70 +81,88 @@ defmodule Mv.Accounts.UserMemberLinkingEmailTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "link with different emails" do
|
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
|
# Create first user and link to a different member
|
||||||
{:ok, other_member} =
|
{:ok, other_member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Other",
|
%{
|
||||||
last_name: "Member",
|
first_name: "Other",
|
||||||
email: "other@example.com"
|
last_name: "Member",
|
||||||
})
|
email: "other@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, _user1} =
|
{:ok, _user1} =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "user1@example.com",
|
%{
|
||||||
member: %{id: other_member.id}
|
email: "user1@example.com",
|
||||||
})
|
member: %{id: other_member.id}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Reload to ensure email sync happened
|
# 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
|
# Create a NEW member with different email
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Charlie",
|
%{
|
||||||
last_name: "Brown",
|
first_name: "Charlie",
|
||||||
email: "charlie@example.com"
|
last_name: "Brown",
|
||||||
})
|
email: "charlie@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Try to create user2 with email that matches the linked other_member
|
# Try to create user2 with email that matches the linked other_member
|
||||||
result =
|
result =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "user1@example.com",
|
%{
|
||||||
member: %{id: member.id}
|
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)
|
# Should fail because user1@example.com is already used by other_member (which is linked to user1)
|
||||||
assert {:error, _error} = result
|
assert {:error, _error} = result
|
||||||
end
|
end
|
||||||
|
|
||||||
test "succeeds for unique emails" do
|
test "succeeds for unique emails", %{actor: actor} do
|
||||||
# Create member
|
# Create member
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "David",
|
%{
|
||||||
last_name: "Wilson",
|
first_name: "David",
|
||||||
email: "david@example.com"
|
last_name: "Wilson",
|
||||||
})
|
email: "david@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Create user with different but unique email
|
# Create user with different but unique email
|
||||||
result =
|
result =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "user@example.com",
|
%{
|
||||||
member: %{id: member.id}
|
email: "user@example.com",
|
||||||
})
|
member: %{id: member.id}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Should succeed
|
# Should succeed
|
||||||
assert {:ok, user} = result
|
assert {:ok, user} = result
|
||||||
|
|
||||||
# Email sync should update member's email to match user's
|
# 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"
|
assert user.member.email == "user@example.com"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "edge cases" do
|
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:
|
# This is the exact scenario from Problem #4:
|
||||||
# 1. Link user and member (both have same email)
|
# 1. Link user and member (both have same email)
|
||||||
# 2. Unlink them (member keeps the email)
|
# 2. Unlink them (member keeps the email)
|
||||||
|
|
@ -133,34 +170,40 @@ defmodule Mv.Accounts.UserMemberLinkingEmailTest do
|
||||||
|
|
||||||
# Create member
|
# Create member
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Emma",
|
%{
|
||||||
last_name: "Davis",
|
first_name: "Emma",
|
||||||
email: "emma@example.com"
|
last_name: "Davis",
|
||||||
})
|
email: "emma@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Create user and link
|
# Create user and link
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "emma@example.com",
|
%{
|
||||||
member: %{id: member.id}
|
email: "emma@example.com",
|
||||||
})
|
member: %{id: member.id}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Verify they are linked
|
# 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.id == member.id
|
||||||
assert user.member.email == "emma@example.com"
|
assert user.member.email == "emma@example.com"
|
||||||
|
|
||||||
# Unlink
|
# 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)
|
assert is_nil(unlinked_user.member_id)
|
||||||
|
|
||||||
# Member still has the email after unlink
|
# Member still has the email after unlink
|
||||||
member = Ash.reload!(member)
|
member = Ash.reload!(member, actor: actor)
|
||||||
assert member.email == "emma@example.com"
|
assert member.email == "emma@example.com"
|
||||||
|
|
||||||
# Relink (should work - this is Problem #4)
|
# 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 {:ok, relinked_user} = result
|
||||||
assert relinked_user.member_id == member.id
|
assert relinked_user.member_id == member.id
|
||||||
|
|
|
||||||
|
|
@ -9,121 +9,150 @@ defmodule Mv.Accounts.UserMemberLinkingTest do
|
||||||
alias Mv.Accounts
|
alias Mv.Accounts
|
||||||
alias Mv.Membership
|
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
|
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
|
# 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
|
# Create member with different email
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "John",
|
%{
|
||||||
last_name: "Doe",
|
first_name: "John",
|
||||||
email: "member@example.com"
|
last_name: "Doe",
|
||||||
})
|
email: "member@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Link user to member
|
# 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
|
# 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
|
assert user_with_member.member.id == member.id
|
||||||
|
|
||||||
# Verify member email was synced to match user email
|
# 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"
|
assert synced_member.email == "user@example.com"
|
||||||
end
|
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
|
# 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} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Jane",
|
%{
|
||||||
last_name: "Smith",
|
first_name: "Jane",
|
||||||
email: "jane@example.com"
|
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
|
# 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
|
assert user_with_member.member.id == member.id
|
||||||
|
|
||||||
# Unlink by setting member to nil
|
# 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
|
# 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)
|
assert is_nil(user_without_member.member)
|
||||||
|
|
||||||
# Verify member still exists independently
|
# 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
|
assert member_still_exists.id == member.id
|
||||||
end
|
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
|
# 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} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Bob",
|
%{
|
||||||
last_name: "Wilson",
|
first_name: "Bob",
|
||||||
email: "bob@example.com"
|
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
|
# 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
|
# Should fail because member is already linked
|
||||||
assert {:error, %Ash.Error.Invalid{}} =
|
assert {:error, %Ash.Error.Invalid{}} =
|
||||||
Accounts.update_user(user2, %{member: %{id: member.id}})
|
Accounts.update_user(user2, %{member: %{id: member.id}}, actor: actor)
|
||||||
end
|
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
|
# 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} =
|
{:ok, member1} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Alice",
|
%{
|
||||||
last_name: "Johnson",
|
first_name: "Alice",
|
||||||
email: "alice@example.com"
|
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
|
# Create second member
|
||||||
{:ok, member2} =
|
{:ok, member2} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Charlie",
|
%{
|
||||||
last_name: "Brown",
|
first_name: "Charlie",
|
||||||
email: "charlie@example.com"
|
last_name: "Brown",
|
||||||
})
|
email: "charlie@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Try to directly change member link (should fail)
|
# Try to directly change member link (should fail)
|
||||||
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
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"
|
# Verify error message mentions "Remove existing member first"
|
||||||
error_messages = Enum.map(errors, & &1.message)
|
error_messages = Enum.map(errors, & &1.message)
|
||||||
assert Enum.any?(error_messages, &String.contains?(&1, "Remove existing member first"))
|
assert Enum.any?(error_messages, &String.contains?(&1, "Remove existing member first"))
|
||||||
|
|
||||||
# Two-step process: first unlink, then link new member
|
# 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
|
# After unlinking, member1 still has the user's email
|
||||||
# Change member1's email to avoid conflict when relinking to member2
|
# 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
|
# 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
|
assert user_with_new_member.member.id == member2.id
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do
|
||||||
alias Mv.Accounts
|
alias Mv.Accounts
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
|
||||||
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
%{actor: system_actor}
|
||||||
|
end
|
||||||
|
|
||||||
describe "User-Member Relationship - Basic Tests" do
|
describe "User-Member Relationship - Basic Tests" do
|
||||||
@valid_user_attrs %{
|
@valid_user_attrs %{
|
||||||
email: "test@example.com"
|
email: "test@example.com"
|
||||||
|
|
@ -16,22 +21,26 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do
|
||||||
email: "john@example.com"
|
email: "john@example.com"
|
||||||
}
|
}
|
||||||
|
|
||||||
test "user can exist without member" do
|
test "user can exist without member", %{actor: actor} do
|
||||||
{:ok, user} = Accounts.create_user(@valid_user_attrs)
|
{:ok, user} = Accounts.create_user(@valid_user_attrs, actor: actor)
|
||||||
assert user.member_id == nil
|
assert user.member_id == nil
|
||||||
|
|
||||||
# Load the relationship to test it
|
# 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
|
assert user_with_member.member == nil
|
||||||
end
|
end
|
||||||
|
|
||||||
test "member can exist without user" do
|
test "member can exist without user", %{actor: actor} do
|
||||||
{:ok, member} = Membership.create_member(@valid_member_attrs)
|
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
|
||||||
assert member.id != nil
|
assert member.id != nil
|
||||||
assert member.first_name == "John"
|
assert member.first_name == "John"
|
||||||
|
|
||||||
# Load the relationship to test it
|
# 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
|
assert member_with_user.user == nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -47,47 +56,58 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do
|
||||||
email: "alice@example.com"
|
email: "alice@example.com"
|
||||||
}
|
}
|
||||||
|
|
||||||
test "user can be linked to member during user creation" do
|
test "user can be linked to member during user creation", %{actor: actor} do
|
||||||
{:ok, member} = Membership.create_member(@valid_member_attrs)
|
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
|
||||||
|
|
||||||
user_attrs = Map.put(@valid_user_attrs, :member, %{id: member.id})
|
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
|
# 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
|
assert user_with_member.member.id == member.id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "member can be linked to user during member creation using manage_relationship" do
|
test "member can be linked to user during member creation using manage_relationship", %{
|
||||||
{:ok, user} = Accounts.create_user(@valid_user_attrs)
|
actor: actor
|
||||||
|
} do
|
||||||
|
{:ok, user} = Accounts.create_user(@valid_user_attrs, actor: actor)
|
||||||
|
|
||||||
member_attrs = Map.put(@valid_member_attrs, :user, %{id: user.id})
|
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
|
# 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
|
assert member_with_user.user.id == user.id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "user can be linked to member during update" do
|
test "user can be linked to member during update", %{actor: actor} do
|
||||||
{:ok, user} = Accounts.create_user(@valid_user_attrs)
|
{:ok, user} = Accounts.create_user(@valid_user_attrs, actor: actor)
|
||||||
{:ok, member} = Membership.create_member(@valid_member_attrs)
|
{: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
|
# 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
|
assert user_with_member.member.id == member.id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "member can be linked to user during update using manage_relationship" do
|
test "member can be linked to user during update using manage_relationship", %{actor: actor} do
|
||||||
{:ok, user} = Accounts.create_user(@valid_user_attrs)
|
{:ok, user} = Accounts.create_user(@valid_user_attrs, actor: actor)
|
||||||
{:ok, member} = Membership.create_member(@valid_member_attrs)
|
{: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
|
# 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
|
assert member_with_user.user.id == user.id
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -103,25 +123,39 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do
|
||||||
email: "bob@example.com"
|
email: "bob@example.com"
|
||||||
}
|
}
|
||||||
|
|
||||||
test "ash resolves inverse relationship automatically" do
|
setup do
|
||||||
{:ok, member} = Membership.create_member(@valid_member_attrs)
|
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})
|
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
|
# Load relationships
|
||||||
{:ok, user_with_member} = Ash.get(Mv.Accounts.User, user.id, load: [:member])
|
{:ok, user_with_member} =
|
||||||
{:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
|
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 user_with_member.member.id == member.id
|
||||||
assert member_with_user.user.id == user.id
|
assert member_with_user.user.id == user.id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "member can find associated user" do
|
test "member can find associated user", %{actor: actor} do
|
||||||
{:ok, member} = Membership.create_member(@valid_member_attrs)
|
{: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
|
assert member_with_user.user.id == user.id
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -137,61 +171,77 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do
|
||||||
email: "charlie@example.com"
|
email: "charlie@example.com"
|
||||||
}
|
}
|
||||||
|
|
||||||
test "prevents overwriting a member of already linked user on update" do
|
setup do
|
||||||
{:ok, existing_member} = Membership.create_member(@valid_member_attrs)
|
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})
|
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} =
|
{:ok, member2} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Dave",
|
%{
|
||||||
last_name: "Wilson",
|
first_name: "Dave",
|
||||||
email: "dave@example.com"
|
last_name: "Wilson",
|
||||||
})
|
email: "dave@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
assert {:error, %Ash.Error.Invalid{}} =
|
assert {:error, %Ash.Error.Invalid{}} =
|
||||||
Accounts.update_user(user, %{member: %{id: member2.id}})
|
Accounts.update_user(user, %{member: %{id: member2.id}}, actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "prevents linking user to already linked member on update" do
|
test "prevents linking user to already linked member on update", %{actor: actor} do
|
||||||
{:ok, existing_user} = Accounts.create_user(@valid_user_attrs)
|
{:ok, existing_user} = Accounts.create_user(@valid_user_attrs, actor: actor)
|
||||||
{:ok, member} = Membership.create_member(@valid_member_attrs)
|
{: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{}} =
|
assert {:error, %Ash.Error.Invalid{}} =
|
||||||
Accounts.update_user(user2, %{member: %{id: member.id}})
|
Accounts.update_user(user2, %{member: %{id: member.id}}, actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "prevents linking member to already linked user on creation" do
|
test "prevents linking member to already linked user on creation", %{actor: actor} do
|
||||||
{:ok, existing_member} = Membership.create_member(@valid_member_attrs)
|
{:ok, existing_member} = Membership.create_member(@valid_member_attrs, actor: actor)
|
||||||
|
|
||||||
user_attrs = Map.put(@valid_user_attrs, :member, %{id: existing_member.id})
|
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{}} =
|
assert {:error, %Ash.Error.Invalid{}} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Dave",
|
%{
|
||||||
last_name: "Wilson",
|
first_name: "Dave",
|
||||||
email: "dave@example.com",
|
last_name: "Wilson",
|
||||||
user: %{id: user.id}
|
email: "dave@example.com",
|
||||||
})
|
user: %{id: user.id}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "prevents linking user to already linked member on creation" do
|
test "prevents linking user to already linked member on creation", %{actor: actor} do
|
||||||
{:ok, existing_user} = Accounts.create_user(@valid_user_attrs)
|
{:ok, existing_user} = Accounts.create_user(@valid_user_attrs, actor: actor)
|
||||||
{:ok, member} = Membership.create_member(@valid_member_attrs)
|
{: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{}} =
|
assert {:error, %Ash.Error.Invalid{}} =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "test5@example.com",
|
%{
|
||||||
member: %{id: member.id}
|
email: "test5@example.com",
|
||||||
})
|
member: %{id: member.id}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -13,23 +13,28 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
|
||||||
|
|
||||||
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
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
|
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} =
|
{:ok, custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "test_field",
|
name: "test_field",
|
||||||
value_type: :string
|
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
|
assert custom_field_with_count.assigned_members_count == 0
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns correct count for custom field with one member" do
|
test "returns correct count for custom field with one member", %{actor: actor} do
|
||||||
{:ok, member} = create_member()
|
{:ok, member} = create_member(actor)
|
||||||
{:ok, custom_field} = create_custom_field("test_field", :string)
|
{:ok, custom_field} = create_custom_field("test_field", :string, actor)
|
||||||
|
|
||||||
{:ok, _custom_field_value} =
|
{:ok, _custom_field_value} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|
|
@ -38,17 +43,17 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
|
||||||
custom_field_id: custom_field.id,
|
custom_field_id: custom_field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "test"}
|
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
|
assert custom_field_with_count.assigned_members_count == 1
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns correct count for custom field with multiple members" do
|
test "returns correct count for custom field with multiple members", %{actor: actor} do
|
||||||
{:ok, member1} = create_member()
|
{:ok, member1} = create_member(actor)
|
||||||
{:ok, member2} = create_member()
|
{:ok, member2} = create_member(actor)
|
||||||
{:ok, member3} = create_member()
|
{:ok, member3} = create_member(actor)
|
||||||
{:ok, custom_field} = create_custom_field("test_field", :string)
|
{:ok, custom_field} = create_custom_field("test_field", :string, actor)
|
||||||
|
|
||||||
# Create custom field value for each member
|
# Create custom field value for each member
|
||||||
for member <- [member1, member2, member3] do
|
for member <- [member1, member2, member3] do
|
||||||
|
|
@ -59,16 +64,16 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
|
||||||
custom_field_id: custom_field.id,
|
custom_field_id: custom_field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "test"}
|
value: %{"_union_type" => "string", "_union_value" => "test"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
end
|
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
|
assert custom_field_with_count.assigned_members_count == 3
|
||||||
end
|
end
|
||||||
|
|
||||||
test "counts distinct members (not multiple values per member)" do
|
test "counts distinct members (not multiple values per member)", %{actor: actor} do
|
||||||
{:ok, member} = create_member()
|
{:ok, member} = create_member(actor)
|
||||||
{:ok, custom_field} = create_custom_field("test_field", :string)
|
{:ok, custom_field} = create_custom_field("test_field", :string, actor)
|
||||||
|
|
||||||
# Create custom field value for member
|
# Create custom field value for member
|
||||||
{:ok, _} =
|
{:ok, _} =
|
||||||
|
|
@ -78,9 +83,9 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
|
||||||
custom_field_id: custom_field.id,
|
custom_field_id: custom_field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "test"}
|
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)
|
# 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
|
assert custom_field_with_count.assigned_members_count == 1
|
||||||
|
|
@ -88,9 +93,9 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "prepare_deletion action" do
|
describe "prepare_deletion action" do
|
||||||
test "loads assigned_members_count for deletion preparation" do
|
test "loads assigned_members_count for deletion preparation", %{actor: actor} do
|
||||||
{:ok, member} = create_member()
|
{:ok, member} = create_member(actor)
|
||||||
{:ok, custom_field} = create_custom_field("test_field", :string)
|
{:ok, custom_field} = create_custom_field("test_field", :string, actor)
|
||||||
|
|
||||||
{:ok, _} =
|
{:ok, _} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|
|
@ -99,43 +104,43 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
|
||||||
custom_field_id: custom_field.id,
|
custom_field_id: custom_field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "test"}
|
value: %{"_union_type" => "string", "_union_value" => "test"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# Use prepare_deletion action
|
# Use prepare_deletion action
|
||||||
[prepared_custom_field] =
|
[prepared_custom_field] =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Query.for_read(:prepare_deletion, %{id: custom_field.id})
|
|> 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.assigned_members_count == 1
|
||||||
assert prepared_custom_field.id == custom_field.id
|
assert prepared_custom_field.id == custom_field.id
|
||||||
end
|
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()
|
non_existent_id = Ash.UUID.generate()
|
||||||
|
|
||||||
result =
|
result =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Query.for_read(:prepare_deletion, %{id: non_existent_id})
|
|> Ash.Query.for_read(:prepare_deletion, %{id: non_existent_id})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
assert result == []
|
assert result == []
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "destroy_with_values action" do
|
describe "destroy_with_values action" do
|
||||||
test "deletes custom field without any values" do
|
test "deletes custom field without any values", %{actor: actor} do
|
||||||
{:ok, custom_field} = create_custom_field("test_field", :string)
|
{: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
|
# Verify custom field is deleted
|
||||||
assert {:error, _} = Ash.get(CustomField, custom_field.id)
|
assert {:error, _} = Ash.get(CustomField, custom_field.id, actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "deletes custom field and cascades to all its values" do
|
test "deletes custom field and cascades to all its values", %{actor: actor} do
|
||||||
{:ok, member} = create_member()
|
{:ok, member} = create_member(actor)
|
||||||
{:ok, custom_field} = create_custom_field("test_field", :string)
|
{:ok, custom_field} = create_custom_field("test_field", :string, actor)
|
||||||
|
|
||||||
{:ok, custom_field_value} =
|
{:ok, custom_field_value} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|
|
@ -144,25 +149,25 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
|
||||||
custom_field_id: custom_field.id,
|
custom_field_id: custom_field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "test"}
|
value: %{"_union_type" => "string", "_union_value" => "test"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# Delete custom field
|
# Delete custom field
|
||||||
assert :ok = Ash.destroy(custom_field)
|
assert :ok = Ash.destroy(custom_field, actor: actor)
|
||||||
|
|
||||||
# Verify custom field is deleted
|
# 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)
|
# 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
|
# Verify member still exists
|
||||||
assert {:ok, _} = Ash.get(Member, member.id)
|
assert {:ok, _} = Ash.get(Member, member.id, actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "deletes only values of the specific custom field" do
|
test "deletes only values of the specific custom field", %{actor: actor} do
|
||||||
{:ok, member} = create_member()
|
{:ok, member} = create_member(actor)
|
||||||
{:ok, custom_field1} = create_custom_field("field1", :string)
|
{:ok, custom_field1} = create_custom_field("field1", :string, actor)
|
||||||
{:ok, custom_field2} = create_custom_field("field2", :string)
|
{:ok, custom_field2} = create_custom_field("field2", :string, actor)
|
||||||
|
|
||||||
# Create value for custom_field1
|
# Create value for custom_field1
|
||||||
{:ok, value1} =
|
{:ok, value1} =
|
||||||
|
|
@ -172,7 +177,7 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
|
||||||
custom_field_id: custom_field1.id,
|
custom_field_id: custom_field1.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "value1"}
|
value: %{"_union_type" => "string", "_union_value" => "value1"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# Create value for custom_field2
|
# Create value for custom_field2
|
||||||
{:ok, value2} =
|
{:ok, value2} =
|
||||||
|
|
@ -182,25 +187,25 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
|
||||||
custom_field_id: custom_field2.id,
|
custom_field_id: custom_field2.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "value2"}
|
value: %{"_union_type" => "string", "_union_value" => "value2"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# Delete custom_field1
|
# Delete custom_field1
|
||||||
assert :ok = Ash.destroy(custom_field1)
|
assert :ok = Ash.destroy(custom_field1, actor: actor)
|
||||||
|
|
||||||
# Verify custom_field1 and value1 are deleted
|
# Verify custom_field1 and value1 are deleted
|
||||||
assert {:error, _} = Ash.get(CustomField, custom_field1.id)
|
assert {:error, _} = Ash.get(CustomField, custom_field1.id, actor: actor)
|
||||||
assert {:error, _} = Ash.get(CustomFieldValue, value1.id)
|
assert {:error, _} = Ash.get(CustomFieldValue, value1.id, actor: actor)
|
||||||
|
|
||||||
# Verify custom_field2 and value2 still exist
|
# Verify custom_field2 and value2 still exist
|
||||||
assert {:ok, _} = Ash.get(CustomField, custom_field2.id)
|
assert {:ok, _} = Ash.get(CustomField, custom_field2.id, actor: actor)
|
||||||
assert {:ok, _} = Ash.get(CustomFieldValue, value2.id)
|
assert {:ok, _} = Ash.get(CustomFieldValue, value2.id, actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "deletes custom field with values from multiple members" do
|
test "deletes custom field with values from multiple members", %{actor: actor} do
|
||||||
{:ok, member1} = create_member()
|
{:ok, member1} = create_member(actor)
|
||||||
{:ok, member2} = create_member()
|
{:ok, member2} = create_member(actor)
|
||||||
{:ok, member3} = create_member()
|
{:ok, member3} = create_member(actor)
|
||||||
{:ok, custom_field} = create_custom_field("test_field", :string)
|
{:ok, custom_field} = create_custom_field("test_field", :string, actor)
|
||||||
|
|
||||||
# Create value for each member
|
# Create value for each member
|
||||||
values =
|
values =
|
||||||
|
|
@ -212,43 +217,43 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
|
||||||
custom_field_id: custom_field.id,
|
custom_field_id: custom_field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "test"}
|
value: %{"_union_type" => "string", "_union_value" => "test"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
value
|
value
|
||||||
end
|
end
|
||||||
|
|
||||||
# Delete custom field
|
# Delete custom field
|
||||||
assert :ok = Ash.destroy(custom_field)
|
assert :ok = Ash.destroy(custom_field, actor: actor)
|
||||||
|
|
||||||
# Verify all values are deleted
|
# Verify all values are deleted
|
||||||
for value <- values do
|
for value <- values do
|
||||||
assert {:error, _} = Ash.get(CustomFieldValue, value.id)
|
assert {:error, _} = Ash.get(CustomFieldValue, value.id, actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Verify all members still exist
|
# Verify all members still exist
|
||||||
for member <- [member1, member2, member3] do
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper functions
|
# Helper functions
|
||||||
defp create_member do
|
defp create_member(actor) do
|
||||||
Member
|
Member
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|> Ash.Changeset.for_create(:create_member, %{
|
||||||
first_name: "Test",
|
first_name: "Test",
|
||||||
last_name: "User#{System.unique_integer([:positive])}",
|
last_name: "User#{System.unique_integer([:positive])}",
|
||||||
email: "test#{System.unique_integer([:positive])}@example.com"
|
email: "test#{System.unique_integer([:positive])}@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp create_custom_field(name, value_type) do
|
defp create_custom_field(name, value_type, actor) do
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "#{name}_#{System.unique_integer([:positive])}",
|
name: "#{name}_#{System.unique_integer([:positive])}",
|
||||||
value_type: value_type
|
value_type: value_type
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,13 @@ defmodule Mv.Membership.CustomFieldShowInOverviewTest do
|
||||||
|
|
||||||
alias Mv.Membership.CustomField
|
alias Mv.Membership.CustomField
|
||||||
|
|
||||||
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
%{actor: system_actor}
|
||||||
|
end
|
||||||
|
|
||||||
describe "show_in_overview attribute" do
|
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} =
|
assert {:ok, custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
|
@ -21,24 +26,24 @@ defmodule Mv.Membership.CustomFieldShowInOverviewTest do
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
show_in_overview: true
|
show_in_overview: true
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert custom_field.show_in_overview == true
|
assert custom_field.show_in_overview == true
|
||||||
end
|
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} =
|
assert {:ok, custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "test_field_hide",
|
name: "test_field_hide",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert custom_field.show_in_overview == true
|
assert custom_field.show_in_overview == true
|
||||||
end
|
end
|
||||||
|
|
||||||
test "updates show_in_overview to true" do
|
test "updates show_in_overview to true", %{actor: actor} do
|
||||||
{:ok, custom_field} =
|
{:ok, custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
|
@ -46,17 +51,17 @@ defmodule Mv.Membership.CustomFieldShowInOverviewTest do
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
show_in_overview: false
|
show_in_overview: false
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert {:ok, updated_field} =
|
assert {:ok, updated_field} =
|
||||||
custom_field
|
custom_field
|
||||||
|> Ash.Changeset.for_update(:update, %{show_in_overview: true})
|
|> Ash.Changeset.for_update(:update, %{show_in_overview: true})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
assert updated_field.show_in_overview == true
|
assert updated_field.show_in_overview == true
|
||||||
end
|
end
|
||||||
|
|
||||||
test "updates show_in_overview to false" do
|
test "updates show_in_overview to false", %{actor: actor} do
|
||||||
{:ok, custom_field} =
|
{:ok, custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
|
@ -64,12 +69,12 @@ defmodule Mv.Membership.CustomFieldShowInOverviewTest do
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
show_in_overview: true
|
show_in_overview: true
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert {:ok, updated_field} =
|
assert {:ok, updated_field} =
|
||||||
custom_field
|
custom_field
|
||||||
|> Ash.Changeset.for_update(:update, %{show_in_overview: false})
|
|> Ash.Changeset.for_update(:update, %{show_in_overview: false})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
assert updated_field.show_in_overview == false
|
assert updated_field.show_in_overview == false
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -13,94 +13,99 @@ defmodule Mv.Membership.CustomFieldSlugTest do
|
||||||
|
|
||||||
alias Mv.Membership.CustomField
|
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
|
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} =
|
{:ok, custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "Mobile Phone",
|
name: "Mobile Phone",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert custom_field.slug == "mobile-phone"
|
assert custom_field.slug == "mobile-phone"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "generates slug from name with German umlauts" do
|
test "generates slug from name with German umlauts", %{actor: actor} do
|
||||||
{:ok, custom_field} =
|
{:ok, custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "Café Müller",
|
name: "Café Müller",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert custom_field.slug == "cafe-muller"
|
assert custom_field.slug == "cafe-muller"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "generates slug with lowercase conversion" do
|
test "generates slug with lowercase conversion", %{actor: actor} do
|
||||||
{:ok, custom_field} =
|
{:ok, custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "TEST NAME",
|
name: "TEST NAME",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert custom_field.slug == "test-name"
|
assert custom_field.slug == "test-name"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "generates slug by removing special characters" do
|
test "generates slug by removing special characters", %{actor: actor} do
|
||||||
{:ok, custom_field} =
|
{:ok, custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "E-Mail & Address!",
|
name: "E-Mail & Address!",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert custom_field.slug == "e-mail-address"
|
assert custom_field.slug == "e-mail-address"
|
||||||
end
|
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} =
|
{:ok, custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "Multiple Spaces",
|
name: "Multiple Spaces",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert custom_field.slug == "multiple-spaces"
|
assert custom_field.slug == "multiple-spaces"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "trims leading and trailing hyphens" do
|
test "trims leading and trailing hyphens", %{actor: actor} do
|
||||||
{:ok, custom_field} =
|
{:ok, custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "-Test-",
|
name: "-Test-",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert custom_field.slug == "test"
|
assert custom_field.slug == "test"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles unicode characters properly (ß becomes ss)" do
|
test "handles unicode characters properly (ß becomes ss)", %{actor: actor} do
|
||||||
{:ok, custom_field} =
|
{:ok, custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "Straße",
|
name: "Straße",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert custom_field.slug == "strasse"
|
assert custom_field.slug == "strasse"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "slug uniqueness" do
|
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
|
# Create first custom field
|
||||||
{:ok, _custom_field} =
|
{:ok, _custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|
|
@ -108,7 +113,7 @@ defmodule Mv.Membership.CustomFieldSlugTest do
|
||||||
name: "Test",
|
name: "Test",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# Attempt to create second custom field with same slug (different case in name)
|
# Attempt to create second custom field with same slug (different case in name)
|
||||||
assert {:error, %Ash.Error.Invalid{} = error} =
|
assert {:error, %Ash.Error.Invalid{} = error} =
|
||||||
|
|
@ -117,19 +122,19 @@ defmodule Mv.Membership.CustomFieldSlugTest do
|
||||||
name: "test",
|
name: "test",
|
||||||
value_type: :integer
|
value_type: :integer
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert Exception.message(error) =~ "has already been taken"
|
assert Exception.message(error) =~ "has already been taken"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "allows custom fields with different slugs" do
|
test "allows custom fields with different slugs", %{actor: actor} do
|
||||||
{:ok, custom_field1} =
|
{:ok, custom_field1} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "Test One",
|
name: "Test One",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
{:ok, custom_field2} =
|
{:ok, custom_field2} =
|
||||||
CustomField
|
CustomField
|
||||||
|
|
@ -137,21 +142,21 @@ defmodule Mv.Membership.CustomFieldSlugTest do
|
||||||
name: "Test Two",
|
name: "Test Two",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert custom_field1.slug == "test-one"
|
assert custom_field1.slug == "test-one"
|
||||||
assert custom_field2.slug == "test-two"
|
assert custom_field2.slug == "test-two"
|
||||||
assert custom_field1.slug != custom_field2.slug
|
assert custom_field1.slug != custom_field2.slug
|
||||||
end
|
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} =
|
{:ok, custom_field1} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "Test!!!",
|
name: "Test!!!",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert custom_field1.slug == "test"
|
assert custom_field1.slug == "test"
|
||||||
|
|
||||||
|
|
@ -162,7 +167,7 @@ defmodule Mv.Membership.CustomFieldSlugTest do
|
||||||
name: "Test???",
|
name: "Test???",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# Should fail with uniqueness constraint error
|
# Should fail with uniqueness constraint error
|
||||||
assert Exception.message(error) =~ "has already been taken"
|
assert Exception.message(error) =~ "has already been taken"
|
||||||
|
|
@ -170,7 +175,7 @@ defmodule Mv.Membership.CustomFieldSlugTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "slug immutability" do
|
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
|
# Attempting to set slug manually should fail because slug is not writable
|
||||||
result =
|
result =
|
||||||
CustomField
|
CustomField
|
||||||
|
|
@ -179,14 +184,14 @@ defmodule Mv.Membership.CustomFieldSlugTest do
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
slug: "custom-slug"
|
slug: "custom-slug"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# Should fail because slug is not an accepted input
|
# Should fail because slug is not an accepted input
|
||||||
assert {:error, %Ash.Error.Invalid{}} = result
|
assert {:error, %Ash.Error.Invalid{}} = result
|
||||||
assert Exception.message(elem(result, 1)) =~ "No such input"
|
assert Exception.message(elem(result, 1)) =~ "No such input"
|
||||||
end
|
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
|
# Create custom field
|
||||||
{:ok, custom_field} =
|
{:ok, custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|
|
@ -194,7 +199,7 @@ defmodule Mv.Membership.CustomFieldSlugTest do
|
||||||
name: "Original Name",
|
name: "Original Name",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
original_slug = custom_field.slug
|
original_slug = custom_field.slug
|
||||||
assert original_slug == "original-name"
|
assert original_slug == "original-name"
|
||||||
|
|
@ -205,7 +210,7 @@ defmodule Mv.Membership.CustomFieldSlugTest do
|
||||||
|> Ash.Changeset.for_update(:update, %{
|
|> Ash.Changeset.for_update(:update, %{
|
||||||
name: "New Different Name"
|
name: "New Different Name"
|
||||||
})
|
})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
# Slug should remain unchanged
|
# Slug should remain unchanged
|
||||||
assert updated_custom_field.slug == original_slug
|
assert updated_custom_field.slug == original_slug
|
||||||
|
|
@ -213,14 +218,14 @@ defmodule Mv.Membership.CustomFieldSlugTest do
|
||||||
assert updated_custom_field.name == "New Different Name"
|
assert updated_custom_field.name == "New Different Name"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "slug cannot be manually updated" do
|
test "slug cannot be manually updated", %{actor: actor} do
|
||||||
{:ok, custom_field} =
|
{:ok, custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "Test",
|
name: "Test",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
original_slug = custom_field.slug
|
original_slug = custom_field.slug
|
||||||
assert original_slug == "test"
|
assert original_slug == "test"
|
||||||
|
|
@ -231,20 +236,20 @@ defmodule Mv.Membership.CustomFieldSlugTest do
|
||||||
|> Ash.Changeset.for_update(:update, %{
|
|> Ash.Changeset.for_update(:update, %{
|
||||||
slug: "new-slug"
|
slug: "new-slug"
|
||||||
})
|
})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
# Should fail because slug is not an accepted input
|
# Should fail because slug is not an accepted input
|
||||||
assert {:error, %Ash.Error.Invalid{}} = result
|
assert {:error, %Ash.Error.Invalid{}} = result
|
||||||
assert Exception.message(elem(result, 1)) =~ "No such input"
|
assert Exception.message(elem(result, 1)) =~ "No such input"
|
||||||
|
|
||||||
# Reload to verify slug hasn't changed
|
# 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"
|
assert reloaded.slug == "test"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "slug edge cases" do
|
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)
|
# Create a name at the maximum length (100 chars)
|
||||||
long_name = String.duplicate("abcdefghij", 10)
|
long_name = String.duplicate("abcdefghij", 10)
|
||||||
# 100 characters exactly
|
# 100 characters exactly
|
||||||
|
|
@ -255,7 +260,7 @@ defmodule Mv.Membership.CustomFieldSlugTest do
|
||||||
name: long_name,
|
name: long_name,
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# Slug should be truncated to maximum 100 characters
|
# Slug should be truncated to maximum 100 characters
|
||||||
assert String.length(custom_field.slug) <= 100
|
assert String.length(custom_field.slug) <= 100
|
||||||
|
|
@ -263,7 +268,7 @@ defmodule Mv.Membership.CustomFieldSlugTest do
|
||||||
assert custom_field.slug == long_name
|
assert custom_field.slug == long_name
|
||||||
end
|
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
|
# When name contains only special characters, slug would be empty
|
||||||
# This should fail validation
|
# This should fail validation
|
||||||
assert {:error, %Ash.Error.Invalid{} = error} =
|
assert {:error, %Ash.Error.Invalid{} = error} =
|
||||||
|
|
@ -272,59 +277,59 @@ defmodule Mv.Membership.CustomFieldSlugTest do
|
||||||
name: "!!!",
|
name: "!!!",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# Should fail because slug would be empty
|
# Should fail because slug would be empty
|
||||||
error_message = Exception.message(error)
|
error_message = Exception.message(error)
|
||||||
assert error_message =~ "Slug cannot be empty" or error_message =~ "is required"
|
assert error_message =~ "Slug cannot be empty" or error_message =~ "is required"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles mixed special characters and text" do
|
test "handles mixed special characters and text", %{actor: actor} do
|
||||||
{:ok, custom_field} =
|
{:ok, custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "Test@#$%Name",
|
name: "Test@#$%Name",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# slugify keeps the hyphen between words
|
# slugify keeps the hyphen between words
|
||||||
assert custom_field.slug == "test-name"
|
assert custom_field.slug == "test-name"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles numbers in name" do
|
test "handles numbers in name", %{actor: actor} do
|
||||||
{:ok, custom_field} =
|
{:ok, custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "Field 123 Test",
|
name: "Field 123 Test",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert custom_field.slug == "field-123-test"
|
assert custom_field.slug == "field-123-test"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles consecutive hyphens in name" do
|
test "handles consecutive hyphens in name", %{actor: actor} do
|
||||||
{:ok, custom_field} =
|
{:ok, custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "Test---Name",
|
name: "Test---Name",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# Should reduce multiple hyphens to single hyphen
|
# Should reduce multiple hyphens to single hyphen
|
||||||
assert custom_field.slug == "test-name"
|
assert custom_field.slug == "test-name"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles name with dots and underscores" do
|
test "handles name with dots and underscores", %{actor: actor} do
|
||||||
{:ok, custom_field} =
|
{:ok, custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "test.field_name",
|
name: "test.field_name",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# Dots and underscores should be handled (either kept or converted)
|
# Dots and underscores should be handled (either kept or converted)
|
||||||
assert custom_field.slug =~ ~r/^[a-z0-9-]+$/
|
assert custom_field.slug =~ ~r/^[a-z0-9-]+$/
|
||||||
|
|
@ -332,45 +337,45 @@ defmodule Mv.Membership.CustomFieldSlugTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "slug in queries and responses" do
|
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} =
|
{:ok, custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "Test",
|
name: "Test",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# Slug should be present in the struct
|
# Slug should be present in the struct
|
||||||
assert Map.has_key?(custom_field, :slug)
|
assert Map.has_key?(custom_field, :slug)
|
||||||
assert custom_field.slug != nil
|
assert custom_field.slug != nil
|
||||||
end
|
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} =
|
{:ok, custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "Test",
|
name: "Test",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# Load it back
|
# 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"
|
assert loaded_custom_field.slug == "test"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "slug is returned in list queries" do
|
test "slug is returned in list queries", %{actor: actor} do
|
||||||
{:ok, custom_field} =
|
{:ok, custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "Test",
|
name: "Test",
|
||||||
value_type: :string
|
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))
|
found = Enum.find(custom_fields, &(&1.id == custom_field.id))
|
||||||
assert found.slug == "test"
|
assert found.slug == "test"
|
||||||
|
|
@ -379,18 +384,18 @@ defmodule Mv.Membership.CustomFieldSlugTest do
|
||||||
|
|
||||||
describe "slug-based lookup (future feature)" do
|
describe "slug-based lookup (future feature)" do
|
||||||
@tag :skip
|
@tag :skip
|
||||||
test "can find custom field by slug" do
|
test "can find custom field by slug", %{actor: actor} do
|
||||||
{:ok, custom_field} =
|
{:ok, custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "Test Field",
|
name: "Test Field",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# This test is for future implementation
|
# This test is for future implementation
|
||||||
# We might add a custom action like :by_slug
|
# 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
|
assert found.id == custom_field.id
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,13 @@ defmodule Mv.Membership.CustomFieldValidationTest do
|
||||||
|
|
||||||
alias Mv.Membership.CustomField
|
alias Mv.Membership.CustomField
|
||||||
|
|
||||||
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
%{actor: system_actor}
|
||||||
|
end
|
||||||
|
|
||||||
describe "name validation" do
|
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)
|
name = String.duplicate("a", 100)
|
||||||
|
|
||||||
assert {:ok, custom_field} =
|
assert {:ok, custom_field} =
|
||||||
|
|
@ -23,13 +28,13 @@ defmodule Mv.Membership.CustomFieldValidationTest do
|
||||||
name: name,
|
name: name,
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert custom_field.name == name
|
assert custom_field.name == name
|
||||||
assert String.length(custom_field.name) == 100
|
assert String.length(custom_field.name) == 100
|
||||||
end
|
end
|
||||||
|
|
||||||
test "rejects name with 101 characters" do
|
test "rejects name with 101 characters", %{actor: actor} do
|
||||||
name = String.duplicate("a", 101)
|
name = String.duplicate("a", 101)
|
||||||
|
|
||||||
assert {:error, changeset} =
|
assert {:error, changeset} =
|
||||||
|
|
@ -38,50 +43,50 @@ defmodule Mv.Membership.CustomFieldValidationTest do
|
||||||
name: name,
|
name: name,
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert [%{field: :name, message: message}] = changeset.errors
|
assert [%{field: :name, message: message}] = changeset.errors
|
||||||
assert message =~ "max" or message =~ "length" or message =~ "100"
|
assert message =~ "max" or message =~ "length" or message =~ "100"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "trims whitespace from name" do
|
test "trims whitespace from name", %{actor: actor} do
|
||||||
assert {:ok, custom_field} =
|
assert {:ok, custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: " test_field ",
|
name: " test_field ",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert custom_field.name == "test_field"
|
assert custom_field.name == "test_field"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "rejects empty name" do
|
test "rejects empty name", %{actor: actor} do
|
||||||
assert {:error, changeset} =
|
assert {:error, changeset} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "",
|
name: "",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert Enum.any?(changeset.errors, fn error -> error.field == :name end)
|
assert Enum.any?(changeset.errors, fn error -> error.field == :name end)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "rejects nil name" do
|
test "rejects nil name", %{actor: actor} do
|
||||||
assert {:error, changeset} =
|
assert {:error, changeset} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert Enum.any?(changeset.errors, fn error -> error.field == :name end)
|
assert Enum.any?(changeset.errors, fn error -> error.field == :name end)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "description validation" do
|
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)
|
description = String.duplicate("a", 500)
|
||||||
|
|
||||||
assert {:ok, custom_field} =
|
assert {:ok, custom_field} =
|
||||||
|
|
@ -91,13 +96,13 @@ defmodule Mv.Membership.CustomFieldValidationTest do
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
description: description
|
description: description
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert custom_field.description == description
|
assert custom_field.description == description
|
||||||
assert String.length(custom_field.description) == 500
|
assert String.length(custom_field.description) == 500
|
||||||
end
|
end
|
||||||
|
|
||||||
test "rejects description with 501 characters" do
|
test "rejects description with 501 characters", %{actor: actor} do
|
||||||
description = String.duplicate("a", 501)
|
description = String.duplicate("a", 501)
|
||||||
|
|
||||||
assert {:error, changeset} =
|
assert {:error, changeset} =
|
||||||
|
|
@ -107,13 +112,13 @@ defmodule Mv.Membership.CustomFieldValidationTest do
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
description: description
|
description: description
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert [%{field: :description, message: message}] = changeset.errors
|
assert [%{field: :description, message: message}] = changeset.errors
|
||||||
assert message =~ "max" or message =~ "length" or message =~ "500"
|
assert message =~ "max" or message =~ "length" or message =~ "500"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "trims whitespace from description" do
|
test "trims whitespace from description", %{actor: actor} do
|
||||||
assert {:ok, custom_field} =
|
assert {:ok, custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
|
@ -121,24 +126,24 @@ defmodule Mv.Membership.CustomFieldValidationTest do
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
description: " A nice description "
|
description: " A nice description "
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert custom_field.description == "A nice description"
|
assert custom_field.description == "A nice description"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "accepts nil description (optional field)" do
|
test "accepts nil description (optional field)", %{actor: actor} do
|
||||||
assert {:ok, custom_field} =
|
assert {:ok, custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "test_field",
|
name: "test_field",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert custom_field.description == nil
|
assert custom_field.description == nil
|
||||||
end
|
end
|
||||||
|
|
||||||
test "accepts empty description after trimming" do
|
test "accepts empty description after trimming", %{actor: actor} do
|
||||||
assert {:ok, custom_field} =
|
assert {:ok, custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
|
@ -146,7 +151,7 @@ defmodule Mv.Membership.CustomFieldValidationTest do
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
description: " "
|
description: " "
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# After trimming whitespace, becomes nil (empty strings are converted to nil)
|
# After trimming whitespace, becomes nil (empty strings are converted to nil)
|
||||||
assert custom_field.description == nil
|
assert custom_field.description == nil
|
||||||
|
|
@ -154,14 +159,14 @@ defmodule Mv.Membership.CustomFieldValidationTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "name uniqueness" do
|
describe "name uniqueness" do
|
||||||
test "rejects duplicate names" do
|
test "rejects duplicate names", %{actor: actor} do
|
||||||
assert {:ok, _} =
|
assert {:ok, _} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "unique_field",
|
name: "unique_field",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert {:error, changeset} =
|
assert {:error, changeset} =
|
||||||
CustomField
|
CustomField
|
||||||
|
|
@ -169,14 +174,14 @@ defmodule Mv.Membership.CustomFieldValidationTest do
|
||||||
name: "unique_field",
|
name: "unique_field",
|
||||||
value_type: :integer
|
value_type: :integer
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert Enum.any?(changeset.errors, fn error -> error.field == :name end)
|
assert Enum.any?(changeset.errors, fn error -> error.field == :name end)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "value_type validation" do
|
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
|
for value_type <- [:string, :integer, :boolean, :date, :email] do
|
||||||
assert {:ok, custom_field} =
|
assert {:ok, custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|
|
@ -184,20 +189,20 @@ defmodule Mv.Membership.CustomFieldValidationTest do
|
||||||
name: "field_#{value_type}",
|
name: "field_#{value_type}",
|
||||||
value_type: value_type
|
value_type: value_type
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert custom_field.value_type == value_type
|
assert custom_field.value_type == value_type
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "rejects invalid value type" do
|
test "rejects invalid value type", %{actor: actor} do
|
||||||
assert {:error, changeset} =
|
assert {:error, changeset} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "invalid_field",
|
name: "invalid_field",
|
||||||
value_type: :invalid_type
|
value_type: :invalid_type
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert [%{field: :value_type}] = changeset.errors
|
assert [%{field: :value_type}] = changeset.errors
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
|
||||||
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Create a test member
|
# Create a test member
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Member
|
Member
|
||||||
|
|
@ -21,7 +23,7 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
|
||||||
last_name: "User",
|
last_name: "User",
|
||||||
email: "test.validation@example.com"
|
email: "test.validation@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Create custom fields for different types
|
# Create custom fields for different types
|
||||||
{:ok, string_field} =
|
{:ok, string_field} =
|
||||||
|
|
@ -30,7 +32,7 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
|
||||||
name: "string_field",
|
name: "string_field",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, integer_field} =
|
{:ok, integer_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|
|
@ -38,7 +40,7 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
|
||||||
name: "integer_field",
|
name: "integer_field",
|
||||||
value_type: :integer
|
value_type: :integer
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, email_field} =
|
{:ok, email_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|
|
@ -46,9 +48,10 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
|
||||||
name: "email_field",
|
name: "email_field",
|
||||||
value_type: :email
|
value_type: :email
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
|
actor: system_actor,
|
||||||
member: member,
|
member: member,
|
||||||
string_field: string_field,
|
string_field: string_field,
|
||||||
integer_field: integer_field,
|
integer_field: integer_field,
|
||||||
|
|
@ -58,6 +61,7 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
|
||||||
|
|
||||||
describe "string value length validation" do
|
describe "string value length validation" do
|
||||||
test "accepts string value with exactly 10,000 characters", %{
|
test "accepts string value with exactly 10,000 characters", %{
|
||||||
|
actor: system_actor,
|
||||||
member: member,
|
member: member,
|
||||||
string_field: string_field
|
string_field: string_field
|
||||||
} do
|
} do
|
||||||
|
|
@ -73,13 +77,14 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
|
||||||
"_union_value" => value_string
|
"_union_value" => value_string
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
assert custom_field_value.value.value == value_string
|
assert custom_field_value.value.value == value_string
|
||||||
assert String.length(custom_field_value.value.value) == 10_000
|
assert String.length(custom_field_value.value.value) == 10_000
|
||||||
end
|
end
|
||||||
|
|
||||||
test "rejects string value with 10,001 characters", %{
|
test "rejects string value with 10,001 characters", %{
|
||||||
|
actor: system_actor,
|
||||||
member: member,
|
member: member,
|
||||||
string_field: string_field
|
string_field: string_field
|
||||||
} do
|
} do
|
||||||
|
|
@ -92,14 +97,18 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
|
||||||
custom_field_id: string_field.id,
|
custom_field_id: string_field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => value_string}
|
value: %{"_union_type" => "string", "_union_value" => value_string}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
assert Enum.any?(changeset.errors, fn error ->
|
assert Enum.any?(changeset.errors, fn error ->
|
||||||
error.field == :value and (error.message =~ "max" or error.message =~ "length")
|
error.field == :value and (error.message =~ "max" or error.message =~ "length")
|
||||||
end)
|
end)
|
||||||
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} =
|
assert {:ok, custom_field_value} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
|
@ -107,12 +116,16 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
|
||||||
custom_field_id: string_field.id,
|
custom_field_id: string_field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => " test value "}
|
value: %{"_union_type" => "string", "_union_value" => " test value "}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
assert custom_field_value.value.value == "test value"
|
assert custom_field_value.value.value == "test value"
|
||||||
end
|
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} =
|
assert {:ok, custom_field_value} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
|
@ -120,13 +133,17 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
|
||||||
custom_field_id: string_field.id,
|
custom_field_id: string_field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => ""}
|
value: %{"_union_type" => "string", "_union_value" => ""}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Empty strings after trimming become nil
|
# Empty strings after trimming become nil
|
||||||
assert custom_field_value.value.value == nil
|
assert custom_field_value.value.value == nil
|
||||||
end
|
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 世界! 🎉 @#$%^&*()"
|
special_string = "Hello 世界! 🎉 @#$%^&*()"
|
||||||
|
|
||||||
assert {:ok, custom_field_value} =
|
assert {:ok, custom_field_value} =
|
||||||
|
|
@ -136,14 +153,18 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
|
||||||
custom_field_id: string_field.id,
|
custom_field_id: string_field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => special_string}
|
value: %{"_union_type" => "string", "_union_value" => special_string}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
assert custom_field_value.value.value == special_string
|
assert custom_field_value.value.value == special_string
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "integer value validation" do
|
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} =
|
assert {:ok, custom_field_value} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
|
@ -151,12 +172,16 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
|
||||||
custom_field_id: integer_field.id,
|
custom_field_id: integer_field.id,
|
||||||
value: %{"_union_type" => "integer", "_union_value" => 42}
|
value: %{"_union_type" => "integer", "_union_value" => 42}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
assert custom_field_value.value.value == 42
|
assert custom_field_value.value.value == 42
|
||||||
end
|
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} =
|
assert {:ok, custom_field_value} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
|
@ -164,12 +189,12 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
|
||||||
custom_field_id: integer_field.id,
|
custom_field_id: integer_field.id,
|
||||||
value: %{"_union_type" => "integer", "_union_value" => -100}
|
value: %{"_union_type" => "integer", "_union_value" => -100}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
assert custom_field_value.value.value == -100
|
assert custom_field_value.value.value == -100
|
||||||
end
|
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} =
|
assert {:ok, custom_field_value} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
|
@ -177,14 +202,18 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
|
||||||
custom_field_id: integer_field.id,
|
custom_field_id: integer_field.id,
|
||||||
value: %{"_union_type" => "integer", "_union_value" => 0}
|
value: %{"_union_type" => "integer", "_union_value" => 0}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
assert custom_field_value.value.value == 0
|
assert custom_field_value.value.value == 0
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "email value validation" do
|
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} =
|
assert {:ok, custom_field_value} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
|
@ -192,12 +221,13 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
|
||||||
custom_field_id: email_field.id,
|
custom_field_id: email_field.id,
|
||||||
value: %{"_union_type" => "email", "_union_value" => nil}
|
value: %{"_union_type" => "email", "_union_value" => nil}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
assert custom_field_value.value.value == nil
|
assert custom_field_value.value.value == nil
|
||||||
end
|
end
|
||||||
|
|
||||||
test "accepts empty string (becomes nil after trim)", %{
|
test "accepts empty string (becomes nil after trim)", %{
|
||||||
|
actor: system_actor,
|
||||||
member: member,
|
member: member,
|
||||||
email_field: email_field
|
email_field: email_field
|
||||||
} do
|
} do
|
||||||
|
|
@ -208,13 +238,13 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
|
||||||
custom_field_id: email_field.id,
|
custom_field_id: email_field.id,
|
||||||
value: %{"_union_type" => "email", "_union_value" => ""}
|
value: %{"_union_type" => "email", "_union_value" => ""}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Empty string after trim should become nil
|
# Empty string after trim should become nil
|
||||||
assert custom_field_value.value.value == nil
|
assert custom_field_value.value.value == nil
|
||||||
end
|
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} =
|
assert {:ok, custom_field_value} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
|
@ -222,12 +252,16 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
|
||||||
custom_field_id: email_field.id,
|
custom_field_id: email_field.id,
|
||||||
value: %{"_union_type" => "email", "_union_value" => "test@example.com"}
|
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"
|
assert custom_field_value.value.value == "test@example.com"
|
||||||
end
|
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} =
|
assert {:error, changeset} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
|
@ -235,12 +269,16 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
|
||||||
custom_field_id: email_field.id,
|
custom_field_id: email_field.id,
|
||||||
value: %{"_union_type" => "email", "_union_value" => "not-an-email"}
|
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)
|
assert Enum.any?(changeset.errors, fn error -> error.field == :value end)
|
||||||
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)
|
# Create an email with >254 chars (243 + 12 = 255)
|
||||||
long_email = String.duplicate("a", 243) <> "@example.com"
|
long_email = String.duplicate("a", 243) <> "@example.com"
|
||||||
|
|
||||||
|
|
@ -251,12 +289,16 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
|
||||||
custom_field_id: email_field.id,
|
custom_field_id: email_field.id,
|
||||||
value: %{"_union_type" => "email", "_union_value" => long_email}
|
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)
|
assert Enum.any?(changeset.errors, fn error -> error.field == :value end)
|
||||||
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} =
|
assert {:ok, custom_field_value} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
|
@ -264,7 +306,7 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
|
||||||
custom_field_id: email_field.id,
|
custom_field_id: email_field.id,
|
||||||
value: %{"_union_type" => "email", "_union_value" => " test@example.com "}
|
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"
|
assert custom_field_value.value.value == "test@example.com"
|
||||||
end
|
end
|
||||||
|
|
@ -272,6 +314,7 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
|
||||||
|
|
||||||
describe "uniqueness constraint" do
|
describe "uniqueness constraint" do
|
||||||
test "rejects duplicate custom_field_id per member", %{
|
test "rejects duplicate custom_field_id per member", %{
|
||||||
|
actor: system_actor,
|
||||||
member: member,
|
member: member,
|
||||||
string_field: string_field
|
string_field: string_field
|
||||||
} do
|
} do
|
||||||
|
|
@ -283,7 +326,7 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
|
||||||
custom_field_id: string_field.id,
|
custom_field_id: string_field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "first value"}
|
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
|
# Try to create second custom field value with same custom_field_id for same member
|
||||||
assert {:error, changeset} =
|
assert {:error, changeset} =
|
||||||
|
|
@ -293,7 +336,7 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
|
||||||
custom_field_id: string_field.id,
|
custom_field_id: string_field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "second value"}
|
value: %{"_union_type" => "string", "_union_value" => "second value"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Should have uniqueness error
|
# Should have uniqueness error
|
||||||
assert Enum.any?(changeset.errors, fn error ->
|
assert Enum.any?(changeset.errors, fn error ->
|
||||||
|
|
|
||||||
|
|
@ -1,70 +1,93 @@
|
||||||
defmodule Mv.Membership.FuzzySearchTest do
|
defmodule Mv.Membership.FuzzySearchTest do
|
||||||
use Mv.DataCase, async: false
|
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
|
test "fuzzy_search/2 function exists" do
|
||||||
assert function_exported?(Mv.Membership.Member, :fuzzy_search, 2)
|
assert function_exported?(Mv.Membership.Member, :fuzzy_search, 2)
|
||||||
end
|
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} =
|
{:ok, john} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "John",
|
%{
|
||||||
last_name: "Doe",
|
first_name: "John",
|
||||||
email: "john.doe@example.com"
|
last_name: "Doe",
|
||||||
})
|
email: "john.doe@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, _jane} =
|
{:ok, _jane} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Adriana",
|
%{
|
||||||
last_name: "Smith",
|
first_name: "Adriana",
|
||||||
email: "adriana.smith@example.com"
|
last_name: "Smith",
|
||||||
})
|
email: "adriana.smith@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, alice} =
|
{:ok, alice} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Alice",
|
%{
|
||||||
last_name: "Johnson",
|
first_name: "Alice",
|
||||||
email: "alice.johnson@example.com"
|
last_name: "Johnson",
|
||||||
})
|
email: "alice.johnson@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Mv.Membership.Member.fuzzy_search(%{
|
|> Mv.Membership.Member.fuzzy_search(%{
|
||||||
query: "john"
|
query: "john"
|
||||||
})
|
})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
assert Enum.map(result, & &1.id) == [john.id, alice.id]
|
assert Enum.map(result, & &1.id) == [john.id, alice.id]
|
||||||
end
|
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} =
|
{:ok, thomas} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Thomas",
|
%{
|
||||||
last_name: "Doe",
|
first_name: "Thomas",
|
||||||
email: "john.doe@example.com"
|
last_name: "Doe",
|
||||||
})
|
email: "john.doe@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, jane} =
|
{:ok, jane} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Jane",
|
%{
|
||||||
last_name: "Smith",
|
first_name: "Jane",
|
||||||
email: "jane.smith@example.com"
|
last_name: "Smith",
|
||||||
})
|
email: "jane.smith@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, _alice} =
|
{:ok, _alice} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Alice",
|
%{
|
||||||
last_name: "Johnson",
|
first_name: "Alice",
|
||||||
email: "alice.johnson@example.com"
|
last_name: "Johnson",
|
||||||
})
|
email: "alice.johnson@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Mv.Membership.Member.fuzzy_search(%{
|
|> Mv.Membership.Member.fuzzy_search(%{
|
||||||
query: "tomas"
|
query: "tomas"
|
||||||
})
|
})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
ids = Enum.map(result, & &1.id)
|
ids = Enum.map(result, & &1.id)
|
||||||
assert thomas.id in ids
|
assert thomas.id in ids
|
||||||
|
|
@ -72,17 +95,21 @@ defmodule Mv.Membership.FuzzySearchTest do
|
||||||
assert not Enum.empty?(ids)
|
assert not Enum.empty?(ids)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "empty query returns all members" do
|
test "empty query returns all members", %{actor: actor} do
|
||||||
{:ok, a} =
|
{: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} =
|
{: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 =
|
result =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Mv.Membership.Member.fuzzy_search(%{query: ""})
|
|> Mv.Membership.Member.fuzzy_search(%{query: ""})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
assert Enum.sort(Enum.map(result, & &1.id))
|
assert Enum.sort(Enum.map(result, & &1.id))
|
||||||
|> Enum.uniq()
|
|> Enum.uniq()
|
||||||
|
|
@ -90,352 +117,435 @@ defmodule Mv.Membership.FuzzySearchTest do
|
||||||
|> Enum.all?(fn id -> id in [a.id, b.id] end)
|
|> Enum.all?(fn id -> id in [a.id, b.id] end)
|
||||||
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} =
|
{:ok, m1} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Num",
|
%{
|
||||||
last_name: "One",
|
first_name: "Num",
|
||||||
email: "n1@example.com",
|
last_name: "One",
|
||||||
postal_code: "12345"
|
email: "n1@example.com",
|
||||||
})
|
postal_code: "12345"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, _m2} =
|
{:ok, _m2} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Num",
|
%{
|
||||||
last_name: "Two",
|
first_name: "Num",
|
||||||
email: "n2@example.com",
|
last_name: "Two",
|
||||||
postal_code: "67890"
|
email: "n2@example.com",
|
||||||
})
|
postal_code: "67890"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Mv.Membership.Member.fuzzy_search(%{query: "345"})
|
|> Mv.Membership.Member.fuzzy_search(%{query: "345"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
ids = Enum.map(result, & &1.id)
|
ids = Enum.map(result, & &1.id)
|
||||||
assert m1.id in ids
|
assert m1.id in ids
|
||||||
end
|
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} =
|
{:ok, m1} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Home",
|
%{
|
||||||
last_name: "One",
|
first_name: "Home",
|
||||||
email: "h1@example.com",
|
last_name: "One",
|
||||||
house_number: "A345B"
|
email: "h1@example.com",
|
||||||
})
|
house_number: "A345B"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, _m2} =
|
{:ok, _m2} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Home",
|
%{
|
||||||
last_name: "Two",
|
first_name: "Home",
|
||||||
email: "h2@example.com",
|
last_name: "Two",
|
||||||
house_number: "77"
|
email: "h2@example.com",
|
||||||
})
|
house_number: "77"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Mv.Membership.Member.fuzzy_search(%{query: "345"})
|
|> Mv.Membership.Member.fuzzy_search(%{query: "345"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
ids = Enum.map(result, & &1.id)
|
ids = Enum.map(result, & &1.id)
|
||||||
assert m1.id in ids
|
assert m1.id in ids
|
||||||
end
|
end
|
||||||
|
|
||||||
test "fuzzy matches street misspelling" do
|
test "fuzzy matches street misspelling", %{actor: actor} do
|
||||||
{:ok, s1} =
|
{:ok, s1} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Road",
|
%{
|
||||||
last_name: "Test",
|
first_name: "Road",
|
||||||
email: "s1@example.com",
|
last_name: "Test",
|
||||||
street: "Main Street"
|
email: "s1@example.com",
|
||||||
})
|
street: "Main Street"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, _s2} =
|
{:ok, _s2} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Road",
|
%{
|
||||||
last_name: "Other",
|
first_name: "Road",
|
||||||
email: "s2@example.com",
|
last_name: "Other",
|
||||||
street: "Second Avenue"
|
email: "s2@example.com",
|
||||||
})
|
street: "Second Avenue"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Mv.Membership.Member.fuzzy_search(%{query: "mainn"})
|
|> Mv.Membership.Member.fuzzy_search(%{query: "mainn"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
ids = Enum.map(result, & &1.id)
|
ids = Enum.map(result, & &1.id)
|
||||||
assert s1.id in ids
|
assert s1.id in ids
|
||||||
end
|
end
|
||||||
|
|
||||||
test "substring in city matches mid-string" do
|
test "substring in city matches mid-string", %{actor: actor} do
|
||||||
{:ok, b} =
|
{:ok, b} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "City",
|
%{
|
||||||
last_name: "One",
|
first_name: "City",
|
||||||
email: "city1@example.com",
|
last_name: "One",
|
||||||
city: "Berlin"
|
email: "city1@example.com",
|
||||||
})
|
city: "Berlin"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, _m} =
|
{:ok, _m} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "City",
|
%{
|
||||||
last_name: "Two",
|
first_name: "City",
|
||||||
email: "city2@example.com",
|
last_name: "Two",
|
||||||
city: "München"
|
email: "city2@example.com",
|
||||||
})
|
city: "München"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Mv.Membership.Member.fuzzy_search(%{query: "erl"})
|
|> Mv.Membership.Member.fuzzy_search(%{query: "erl"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
ids = Enum.map(result, & &1.id)
|
ids = Enum.map(result, & &1.id)
|
||||||
assert b.id in ids
|
assert b.id in ids
|
||||||
end
|
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} =
|
{:ok, member} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "John",
|
%{
|
||||||
last_name: "Doe",
|
first_name: "John",
|
||||||
email: "john.doe@example.com"
|
last_name: "Doe",
|
||||||
})
|
email: "john.doe@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, _other} =
|
{:ok, _other} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Jane",
|
%{
|
||||||
last_name: "Smith",
|
first_name: "Jane",
|
||||||
email: "jane.smith@example.com"
|
last_name: "Smith",
|
||||||
})
|
email: "jane.smith@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Mv.Membership.Member.fuzzy_search(%{query: "john doe"})
|
|> Mv.Membership.Member.fuzzy_search(%{query: "john doe"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
ids = Enum.map(result, & &1.id)
|
ids = Enum.map(result, & &1.id)
|
||||||
assert member.id in ids
|
assert member.id in ids
|
||||||
end
|
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} =
|
{:ok, member} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Mary",
|
%{
|
||||||
last_name: "Jane",
|
first_name: "Mary",
|
||||||
email: "mary.jane@example.com"
|
last_name: "Jane",
|
||||||
})
|
email: "mary.jane@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Mv.Membership.Member.fuzzy_search(%{query: "mary jane"})
|
|> Mv.Membership.Member.fuzzy_search(%{query: "mary jane"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
ids = Enum.map(result, & &1.id)
|
ids = Enum.map(result, & &1.id)
|
||||||
assert member.id in ids
|
assert member.id in ids
|
||||||
end
|
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} =
|
{:ok, member} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Test",
|
%{
|
||||||
last_name: "User",
|
first_name: "Test",
|
||||||
email: "test.user@example.com"
|
last_name: "User",
|
||||||
})
|
email: "test.user@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, _other} =
|
{:ok, _other} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Other",
|
%{
|
||||||
last_name: "Person",
|
first_name: "Other",
|
||||||
email: "other.person@different.org"
|
last_name: "Person",
|
||||||
})
|
email: "other.person@different.org"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Mv.Membership.Member.fuzzy_search(%{query: "example"})
|
|> Mv.Membership.Member.fuzzy_search(%{query: "example"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
ids = Enum.map(result, & &1.id)
|
ids = Enum.map(result, & &1.id)
|
||||||
assert member.id in ids
|
assert member.id in ids
|
||||||
end
|
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} =
|
{:ok, member} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Dot",
|
%{
|
||||||
last_name: "Test",
|
first_name: "Dot",
|
||||||
email: "dot.test@example.com"
|
last_name: "Test",
|
||||||
})
|
email: "dot.test@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, _other} =
|
{:ok, _other} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "No",
|
%{
|
||||||
last_name: "Dot",
|
first_name: "No",
|
||||||
email: "nodot@example.com"
|
last_name: "Dot",
|
||||||
})
|
email: "nodot@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Mv.Membership.Member.fuzzy_search(%{query: "dot.test"})
|
|> Mv.Membership.Member.fuzzy_search(%{query: "dot.test"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
ids = Enum.map(result, & &1.id)
|
ids = Enum.map(result, & &1.id)
|
||||||
assert member.id in ids
|
assert member.id in ids
|
||||||
end
|
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} =
|
{:ok, member} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Mary-Jane",
|
%{
|
||||||
last_name: "Watson",
|
first_name: "Mary-Jane",
|
||||||
email: "mary.jane@example.com"
|
last_name: "Watson",
|
||||||
})
|
email: "mary.jane@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, _other} =
|
{:ok, _other} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Mary",
|
%{
|
||||||
last_name: "Smith",
|
first_name: "Mary",
|
||||||
email: "mary.smith@example.com"
|
last_name: "Smith",
|
||||||
})
|
email: "mary.smith@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Mv.Membership.Member.fuzzy_search(%{query: "mary-jane"})
|
|> Mv.Membership.Member.fuzzy_search(%{query: "mary-jane"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
ids = Enum.map(result, & &1.id)
|
ids = Enum.map(result, & &1.id)
|
||||||
assert member.id in ids
|
assert member.id in ids
|
||||||
end
|
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} =
|
{:ok, member} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Jörg",
|
%{
|
||||||
last_name: "Schmidt",
|
first_name: "Jörg",
|
||||||
email: "joerg.schmidt@example.com"
|
last_name: "Schmidt",
|
||||||
})
|
email: "joerg.schmidt@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, _other} =
|
{:ok, _other} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "John",
|
%{
|
||||||
last_name: "Smith",
|
first_name: "John",
|
||||||
email: "john.smith@example.com"
|
last_name: "Smith",
|
||||||
})
|
email: "john.smith@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Mv.Membership.Member.fuzzy_search(%{query: "jörg"})
|
|> Mv.Membership.Member.fuzzy_search(%{query: "jörg"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
ids = Enum.map(result, & &1.id)
|
ids = Enum.map(result, & &1.id)
|
||||||
assert member.id in ids
|
assert member.id in ids
|
||||||
end
|
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} =
|
{:ok, member} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Märta",
|
%{
|
||||||
last_name: "Andersson",
|
first_name: "Märta",
|
||||||
email: "maerta.andersson@example.com"
|
last_name: "Andersson",
|
||||||
})
|
email: "maerta.andersson@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, _other} =
|
{:ok, _other} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Marta",
|
%{
|
||||||
last_name: "Johnson",
|
first_name: "Marta",
|
||||||
email: "marta.johnson@example.com"
|
last_name: "Johnson",
|
||||||
})
|
email: "marta.johnson@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Mv.Membership.Member.fuzzy_search(%{query: "märta"})
|
|> Mv.Membership.Member.fuzzy_search(%{query: "märta"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
ids = Enum.map(result, & &1.id)
|
ids = Enum.map(result, & &1.id)
|
||||||
assert member.id in ids
|
assert member.id in ids
|
||||||
end
|
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} =
|
{:ok, member} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Günther",
|
%{
|
||||||
last_name: "Müller",
|
first_name: "Günther",
|
||||||
email: "guenther.mueller@example.com"
|
last_name: "Müller",
|
||||||
})
|
email: "guenther.mueller@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, _other} =
|
{:ok, _other} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Gunter",
|
%{
|
||||||
last_name: "Miller",
|
first_name: "Gunter",
|
||||||
email: "gunter.miller@example.com"
|
last_name: "Miller",
|
||||||
})
|
email: "gunter.miller@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Mv.Membership.Member.fuzzy_search(%{query: "müller"})
|
|> Mv.Membership.Member.fuzzy_search(%{query: "müller"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
ids = Enum.map(result, & &1.id)
|
ids = Enum.map(result, & &1.id)
|
||||||
assert member.id in ids
|
assert member.id in ids
|
||||||
end
|
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} =
|
{:ok, member} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Müller",
|
%{
|
||||||
last_name: "Schmidt",
|
first_name: "Müller",
|
||||||
email: "mueller.schmidt@example.com"
|
last_name: "Schmidt",
|
||||||
})
|
email: "mueller.schmidt@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, _other} =
|
{:ok, _other} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Miller",
|
%{
|
||||||
last_name: "Smith",
|
first_name: "Miller",
|
||||||
email: "miller.smith@example.com"
|
last_name: "Smith",
|
||||||
})
|
email: "miller.smith@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Mv.Membership.Member.fuzzy_search(%{query: "muller"})
|
|> Mv.Membership.Member.fuzzy_search(%{query: "muller"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
ids = Enum.map(result, & &1.id)
|
ids = Enum.map(result, & &1.id)
|
||||||
assert member.id in ids
|
assert member.id in ids
|
||||||
end
|
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} =
|
{:ok, _member} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Test",
|
%{
|
||||||
last_name: "User",
|
first_name: "Test",
|
||||||
email: "test@example.com"
|
last_name: "User",
|
||||||
})
|
email: "test@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
long_query = String.duplicate("a", 1000)
|
long_query = String.duplicate("a", 1000)
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Mv.Membership.Member.fuzzy_search(%{query: long_query})
|
|> Mv.Membership.Member.fuzzy_search(%{query: long_query})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
# Should not crash, may return empty or some results
|
# Should not crash, may return empty or some results
|
||||||
assert is_list(result)
|
assert is_list(result)
|
||||||
end
|
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} =
|
{:ok, _member} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Test",
|
%{
|
||||||
last_name: "User",
|
first_name: "Test",
|
||||||
email: "test@example.com"
|
last_name: "User",
|
||||||
})
|
email: "test@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
very_long_query = String.duplicate("test query ", 1000)
|
very_long_query = String.duplicate("test query ", 1000)
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Mv.Membership.Member.fuzzy_search(%{query: very_long_query})
|
|> Mv.Membership.Member.fuzzy_search(%{query: very_long_query})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
# Should not crash, may return empty or some results
|
# Should not crash, may return empty or some results
|
||||||
assert is_list(result)
|
assert is_list(result)
|
||||||
|
|
|
||||||
|
|
@ -13,64 +13,87 @@ defmodule Mv.Membership.MemberAvailableForLinkingTest do
|
||||||
|
|
||||||
describe "available_for_linking/2" do
|
describe "available_for_linking/2" do
|
||||||
setup do
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Create 5 unlinked members with distinct names
|
# Create 5 unlinked members with distinct names
|
||||||
{:ok, member1} =
|
{:ok, member1} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Alice",
|
%{
|
||||||
last_name: "Anderson",
|
first_name: "Alice",
|
||||||
email: "alice@example.com"
|
last_name: "Anderson",
|
||||||
})
|
email: "alice@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, member2} =
|
{:ok, member2} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Bob",
|
%{
|
||||||
last_name: "Williams",
|
first_name: "Bob",
|
||||||
email: "bob@example.com"
|
last_name: "Williams",
|
||||||
})
|
email: "bob@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, member3} =
|
{:ok, member3} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Charlie",
|
%{
|
||||||
last_name: "Davis",
|
first_name: "Charlie",
|
||||||
email: "charlie@example.com"
|
last_name: "Davis",
|
||||||
})
|
email: "charlie@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, member4} =
|
{:ok, member4} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Diana",
|
%{
|
||||||
last_name: "Martinez",
|
first_name: "Diana",
|
||||||
email: "diana@example.com"
|
last_name: "Martinez",
|
||||||
})
|
email: "diana@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, member5} =
|
{:ok, member5} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Emma",
|
%{
|
||||||
last_name: "Taylor",
|
first_name: "Emma",
|
||||||
email: "emma@example.com"
|
last_name: "Taylor",
|
||||||
})
|
email: "emma@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
unlinked_members = [member1, member2, member3, member4, member5]
|
unlinked_members = [member1, member2, member3, member4, member5]
|
||||||
|
|
||||||
# Create 2 linked members (with users)
|
# 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} =
|
{:ok, linked_member1} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Linked",
|
%{
|
||||||
last_name: "Member1",
|
first_name: "Linked",
|
||||||
email: "linked1@example.com",
|
last_name: "Member1",
|
||||||
user: %{id: user1.id}
|
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} =
|
{:ok, linked_member2} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Linked",
|
%{
|
||||||
last_name: "Member2",
|
first_name: "Linked",
|
||||||
email: "linked2@example.com",
|
last_name: "Member2",
|
||||||
user: %{id: user2.id}
|
email: "linked2@example.com",
|
||||||
})
|
user: %{id: user2.id}
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
unlinked_members: unlinked_members,
|
unlinked_members: unlinked_members,
|
||||||
|
|
@ -82,11 +105,13 @@ defmodule Mv.Membership.MemberAvailableForLinkingTest do
|
||||||
unlinked_members: unlinked_members,
|
unlinked_members: unlinked_members,
|
||||||
linked_members: _linked_members
|
linked_members: _linked_members
|
||||||
} do
|
} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Call the action without any arguments
|
# Call the action without any arguments
|
||||||
members =
|
members =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Ash.Query.for_read(:available_for_linking, %{})
|
|> 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
|
# Should return only the 5 unlinked members, not the 2 linked ones
|
||||||
assert length(members) == 5
|
assert length(members) == 5
|
||||||
|
|
@ -98,25 +123,32 @@ defmodule Mv.Membership.MemberAvailableForLinkingTest do
|
||||||
|
|
||||||
# Verify none of the returned members have a user_id
|
# Verify none of the returned members have a user_id
|
||||||
Enum.each(members, fn member ->
|
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)
|
assert is_nil(member_with_user.user)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "limits results to 10 members even when more exist" do
|
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)
|
# Create 15 additional unlinked members (total 20 unlinked)
|
||||||
for i <- 6..20 do
|
for i <- 6..20 do
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Extra#{i}",
|
%{
|
||||||
last_name: "Member#{i}",
|
first_name: "Extra#{i}",
|
||||||
email: "extra#{i}@example.com"
|
last_name: "Member#{i}",
|
||||||
})
|
email: "extra#{i}@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
members =
|
members =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Ash.Query.for_read(:available_for_linking, %{})
|
|> Ash.Query.for_read(:available_for_linking, %{})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
# Should be limited to 10
|
# Should be limited to 10
|
||||||
assert length(members) == 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", %{
|
test "email match: returns only member with matching email when exists", %{
|
||||||
unlinked_members: unlinked_members
|
unlinked_members: unlinked_members
|
||||||
} do
|
} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Get one of the unlinked members' email
|
# Get one of the unlinked members' email
|
||||||
target_member = List.first(unlinked_members)
|
target_member = List.first(unlinked_members)
|
||||||
user_email = target_member.email
|
user_email = target_member.email
|
||||||
|
|
@ -132,7 +166,7 @@ defmodule Mv.Membership.MemberAvailableForLinkingTest do
|
||||||
raw_members =
|
raw_members =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Ash.Query.for_read(:available_for_linking, %{user_email: user_email})
|
|> 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)
|
# Apply email match filtering (sorted results come from query)
|
||||||
# When user_email matches, only that member should be returned
|
# When user_email matches, only that member should be returned
|
||||||
|
|
@ -145,13 +179,15 @@ defmodule Mv.Membership.MemberAvailableForLinkingTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "email match: returns all unlinked members when no email match" do
|
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
|
# Use an email that doesn't match any member
|
||||||
non_matching_email = "nonexistent@example.com"
|
non_matching_email = "nonexistent@example.com"
|
||||||
|
|
||||||
raw_members =
|
raw_members =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Ash.Query.for_read(:available_for_linking, %{user_email: non_matching_email})
|
|> Ash.Query.for_read(:available_for_linking, %{user_email: non_matching_email})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
# Apply email match filtering
|
# Apply email match filtering
|
||||||
members = Mv.Membership.Member.filter_by_email_match(raw_members, non_matching_email)
|
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", %{
|
test "search query: filters by first_name, last_name, and email", %{
|
||||||
unlinked_members: _unlinked_members
|
unlinked_members: _unlinked_members
|
||||||
} do
|
} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Search by first name
|
# Search by first name
|
||||||
members =
|
members =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Ash.Query.for_read(:available_for_linking, %{search_query: "Alice"})
|
|> Ash.Query.for_read(:available_for_linking, %{search_query: "Alice"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
assert length(members) == 1
|
assert length(members) == 1
|
||||||
assert List.first(members).first_name == "Alice"
|
assert List.first(members).first_name == "Alice"
|
||||||
|
|
@ -176,7 +214,7 @@ defmodule Mv.Membership.MemberAvailableForLinkingTest do
|
||||||
members =
|
members =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Ash.Query.for_read(:available_for_linking, %{search_query: "Williams"})
|
|> Ash.Query.for_read(:available_for_linking, %{search_query: "Williams"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
assert length(members) == 1
|
assert length(members) == 1
|
||||||
assert List.first(members).last_name == "Williams"
|
assert List.first(members).last_name == "Williams"
|
||||||
|
|
@ -185,7 +223,7 @@ defmodule Mv.Membership.MemberAvailableForLinkingTest do
|
||||||
members =
|
members =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Ash.Query.for_read(:available_for_linking, %{search_query: "charlie@"})
|
|> Ash.Query.for_read(:available_for_linking, %{search_query: "charlie@"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
assert length(members) == 1
|
assert length(members) == 1
|
||||||
assert List.first(members).email == "charlie@example.com"
|
assert List.first(members).email == "charlie@example.com"
|
||||||
|
|
@ -194,12 +232,13 @@ defmodule Mv.Membership.MemberAvailableForLinkingTest do
|
||||||
members =
|
members =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Ash.Query.for_read(:available_for_linking, %{search_query: "NonExistent"})
|
|> Ash.Query.for_read(:available_for_linking, %{search_query: "NonExistent"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
assert Enum.empty?(members)
|
assert Enum.empty?(members)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "user_email takes precedence over search_query", %{unlinked_members: unlinked_members} do
|
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)
|
target_member = List.first(unlinked_members)
|
||||||
|
|
||||||
# Pass both email match and search query that would match different 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,
|
user_email: target_member.email,
|
||||||
search_query: "Bob"
|
search_query: "Bob"
|
||||||
})
|
})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
# Apply email-match filter (as LiveView does)
|
# Apply email-match filter (as LiveView does)
|
||||||
members = Mv.Membership.Member.filter_by_email_match(raw_members, target_member.email)
|
members = Mv.Membership.Member.filter_by_email_match(raw_members, target_member.email)
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,13 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
alias Mv.MembershipFees.CalendarCycles
|
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
|
# Helper to create a membership fee type
|
||||||
defp create_fee_type(attrs) do
|
defp create_fee_type(attrs, actor) do
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||||
amount: Decimal.new("50.00"),
|
amount: Decimal.new("50.00"),
|
||||||
|
|
@ -21,11 +26,11 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
|
||||||
|
|
||||||
MembershipFeeType
|
MembershipFeeType
|
||||||
|> Ash.Changeset.for_create(:create, attrs)
|
|> Ash.Changeset.for_create(:create, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a member
|
# Helper to create a member
|
||||||
defp create_member(attrs) do
|
defp create_member(attrs, actor) do
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
first_name: "Test",
|
first_name: "Test",
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
|
|
@ -36,11 +41,11 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
|
||||||
|
|
||||||
Member
|
Member
|
||||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a cycle
|
# Helper to create a cycle
|
||||||
defp create_cycle(member, fee_type, attrs) do
|
defp create_cycle(member, fee_type, attrs, actor) do
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
cycle_start: ~D[2024-01-01],
|
cycle_start: ~D[2024-01-01],
|
||||||
amount: Decimal.new("50.00"),
|
amount: Decimal.new("50.00"),
|
||||||
|
|
@ -53,153 +58,198 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
|
||||||
|
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|> Ash.Changeset.for_create(:create, attrs)
|
|> Ash.Changeset.for_create(:create, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "current_cycle_status" do
|
describe "current_cycle_status" do
|
||||||
test "returns status of current cycle for member with active cycle" do
|
test "returns status of current cycle for member with active cycle", %{actor: actor} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
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)
|
# Create a cycle that is active today (2024-01-01 to 2024-12-31)
|
||||||
# Assuming today is in 2024
|
# Assuming today is in 2024
|
||||||
today = Date.utc_today()
|
today = Date.utc_today()
|
||||||
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
create_cycle(
|
||||||
cycle_start: cycle_start,
|
member,
|
||||||
status: :paid
|
fee_type,
|
||||||
})
|
%{
|
||||||
|
cycle_start: cycle_start,
|
||||||
|
status: :paid
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
member = Ash.load!(member, :current_cycle_status)
|
member = Ash.load!(member, :current_cycle_status, actor: actor)
|
||||||
assert member.current_cycle_status == :paid
|
assert member.current_cycle_status == :paid
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns nil for member without current cycle" do
|
test "returns nil for member without current cycle", %{actor: actor} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||||
|
|
||||||
# Create a cycle in the past (not current)
|
# Create a cycle in the past (not current)
|
||||||
create_cycle(member, fee_type, %{
|
create_cycle(
|
||||||
cycle_start: ~D[2020-01-01],
|
member,
|
||||||
status: :paid
|
fee_type,
|
||||||
})
|
%{
|
||||||
|
cycle_start: ~D[2020-01-01],
|
||||||
|
status: :paid
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
member = Ash.load!(member, :current_cycle_status)
|
member = Ash.load!(member, :current_cycle_status, actor: actor)
|
||||||
assert member.current_cycle_status == nil
|
assert member.current_cycle_status == nil
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns nil for member without cycles" do
|
test "returns nil for member without cycles", %{actor: actor} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||||
|
|
||||||
member = Ash.load!(member, :current_cycle_status)
|
member = Ash.load!(member, :current_cycle_status, actor: actor)
|
||||||
assert member.current_cycle_status == nil
|
assert member.current_cycle_status == nil
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns status of current cycle for monthly interval" do
|
test "returns status of current cycle for monthly interval", %{actor: actor} do
|
||||||
fee_type = create_fee_type(%{interval: :monthly})
|
fee_type = create_fee_type(%{interval: :monthly}, actor)
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||||
|
|
||||||
# Create a cycle that is active today (current month)
|
# Create a cycle that is active today (current month)
|
||||||
today = Date.utc_today()
|
today = Date.utc_today()
|
||||||
cycle_start = CalendarCycles.calculate_cycle_start(today, :monthly)
|
cycle_start = CalendarCycles.calculate_cycle_start(today, :monthly)
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
create_cycle(
|
||||||
cycle_start: cycle_start,
|
member,
|
||||||
status: :unpaid
|
fee_type,
|
||||||
})
|
%{
|
||||||
|
cycle_start: cycle_start,
|
||||||
|
status: :unpaid
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
member = Ash.load!(member, :current_cycle_status)
|
member = Ash.load!(member, :current_cycle_status, actor: actor)
|
||||||
assert member.current_cycle_status == :unpaid
|
assert member.current_cycle_status == :unpaid
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "last_cycle_status" do
|
describe "last_cycle_status" do
|
||||||
test "returns status of last completed cycle" do
|
test "returns status of last completed cycle", %{actor: actor} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||||
|
|
||||||
# Create cycles: 2022 (completed), 2023 (completed), 2024 (current)
|
# Create cycles: 2022 (completed), 2023 (completed), 2024 (current)
|
||||||
today = Date.utc_today()
|
today = Date.utc_today()
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
create_cycle(
|
||||||
cycle_start: ~D[2022-01-01],
|
member,
|
||||||
status: :paid
|
fee_type,
|
||||||
})
|
%{
|
||||||
|
cycle_start: ~D[2022-01-01],
|
||||||
|
status: :paid
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
create_cycle(
|
||||||
cycle_start: ~D[2023-01-01],
|
member,
|
||||||
status: :unpaid
|
fee_type,
|
||||||
})
|
%{
|
||||||
|
cycle_start: ~D[2023-01-01],
|
||||||
|
status: :unpaid
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
# Current cycle
|
# Current cycle
|
||||||
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
create_cycle(
|
||||||
cycle_start: cycle_start,
|
member,
|
||||||
status: :paid
|
fee_type,
|
||||||
})
|
%{
|
||||||
|
cycle_start: cycle_start,
|
||||||
|
status: :paid
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
member = Ash.load!(member, :last_cycle_status)
|
member = Ash.load!(member, :last_cycle_status, actor: actor)
|
||||||
# Should return status of 2023 (last completed)
|
# Should return status of 2023 (last completed)
|
||||||
assert member.last_cycle_status == :unpaid
|
assert member.last_cycle_status == :unpaid
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns nil for member without completed cycles" do
|
test "returns nil for member without completed cycles", %{actor: actor} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||||
|
|
||||||
# Only create current cycle (not completed yet)
|
# Only create current cycle (not completed yet)
|
||||||
today = Date.utc_today()
|
today = Date.utc_today()
|
||||||
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
create_cycle(
|
||||||
cycle_start: cycle_start,
|
member,
|
||||||
status: :paid
|
fee_type,
|
||||||
})
|
%{
|
||||||
|
cycle_start: cycle_start,
|
||||||
|
status: :paid
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
member = Ash.load!(member, :last_cycle_status)
|
member = Ash.load!(member, :last_cycle_status, actor: actor)
|
||||||
assert member.last_cycle_status == nil
|
assert member.last_cycle_status == nil
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns nil for member without cycles" do
|
test "returns nil for member without cycles", %{actor: actor} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||||
|
|
||||||
member = Ash.load!(member, :last_cycle_status)
|
member = Ash.load!(member, :last_cycle_status, actor: actor)
|
||||||
assert member.last_cycle_status == nil
|
assert member.last_cycle_status == nil
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns status of last completed cycle for monthly interval" do
|
test "returns status of last completed cycle for monthly interval", %{actor: actor} do
|
||||||
fee_type = create_fee_type(%{interval: :monthly})
|
fee_type = create_fee_type(%{interval: :monthly}, actor)
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||||
|
|
||||||
today = Date.utc_today()
|
today = Date.utc_today()
|
||||||
# Create cycles: last month (completed), current month (not completed)
|
# Create cycles: last month (completed), current month (not completed)
|
||||||
last_month_start = Date.add(today, -32) |> CalendarCycles.calculate_cycle_start(:monthly)
|
last_month_start = Date.add(today, -32) |> CalendarCycles.calculate_cycle_start(:monthly)
|
||||||
current_month_start = CalendarCycles.calculate_cycle_start(today, :monthly)
|
current_month_start = CalendarCycles.calculate_cycle_start(today, :monthly)
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
create_cycle(
|
||||||
cycle_start: last_month_start,
|
member,
|
||||||
status: :paid
|
fee_type,
|
||||||
})
|
%{
|
||||||
|
cycle_start: last_month_start,
|
||||||
|
status: :paid
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
create_cycle(
|
||||||
cycle_start: current_month_start,
|
member,
|
||||||
status: :unpaid
|
fee_type,
|
||||||
})
|
%{
|
||||||
|
cycle_start: current_month_start,
|
||||||
|
status: :unpaid
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
member = Ash.load!(member, :last_cycle_status)
|
member = Ash.load!(member, :last_cycle_status, actor: actor)
|
||||||
# Should return status of last month (last completed)
|
# Should return status of last month (last completed)
|
||||||
assert member.last_cycle_status == :paid
|
assert member.last_cycle_status == :paid
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "overdue_count" do
|
describe "overdue_count" do
|
||||||
test "counts only unpaid cycles that have ended" do
|
test "counts only unpaid cycles that have ended", %{actor: actor} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||||
|
|
||||||
today = Date.utc_today()
|
today = Date.utc_today()
|
||||||
|
|
||||||
|
|
@ -209,23 +259,38 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
|
||||||
# 2024: unpaid, current (not overdue)
|
# 2024: unpaid, current (not overdue)
|
||||||
# 2025: unpaid, future (not overdue)
|
# 2025: unpaid, future (not overdue)
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
create_cycle(
|
||||||
cycle_start: ~D[2022-01-01],
|
member,
|
||||||
status: :unpaid
|
fee_type,
|
||||||
})
|
%{
|
||||||
|
cycle_start: ~D[2022-01-01],
|
||||||
|
status: :unpaid
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
create_cycle(
|
||||||
cycle_start: ~D[2023-01-01],
|
member,
|
||||||
status: :paid
|
fee_type,
|
||||||
})
|
%{
|
||||||
|
cycle_start: ~D[2023-01-01],
|
||||||
|
status: :paid
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
# Current cycle
|
# Current cycle
|
||||||
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
create_cycle(
|
||||||
cycle_start: cycle_start,
|
member,
|
||||||
status: :unpaid
|
fee_type,
|
||||||
})
|
%{
|
||||||
|
cycle_start: cycle_start,
|
||||||
|
status: :unpaid
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
# Future cycle (if we're not at the end of the year)
|
# Future cycle (if we're not at the end of the year)
|
||||||
next_year = today.year + 1
|
next_year = today.year + 1
|
||||||
|
|
@ -233,42 +298,52 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
|
||||||
if today.month < 12 or today.day < 31 do
|
if today.month < 12 or today.day < 31 do
|
||||||
next_year_start = Date.new!(next_year, 1, 1)
|
next_year_start = Date.new!(next_year, 1, 1)
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
create_cycle(
|
||||||
cycle_start: next_year_start,
|
member,
|
||||||
status: :unpaid
|
fee_type,
|
||||||
})
|
%{
|
||||||
|
cycle_start: next_year_start,
|
||||||
|
status: :unpaid
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
member = Ash.load!(member, :overdue_count)
|
member = Ash.load!(member, :overdue_count, actor: actor)
|
||||||
# Should only count 2022 (unpaid and ended)
|
# Should only count 2022 (unpaid and ended)
|
||||||
assert member.overdue_count == 1
|
assert member.overdue_count == 1
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns 0 when no overdue cycles" do
|
test "returns 0 when no overdue cycles", %{actor: actor} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||||
|
|
||||||
# Create only paid cycles
|
# Create only paid cycles
|
||||||
create_cycle(member, fee_type, %{
|
create_cycle(
|
||||||
cycle_start: ~D[2022-01-01],
|
member,
|
||||||
status: :paid
|
fee_type,
|
||||||
})
|
%{
|
||||||
|
cycle_start: ~D[2022-01-01],
|
||||||
|
status: :paid
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
member = Ash.load!(member, :overdue_count)
|
member = Ash.load!(member, :overdue_count, actor: actor)
|
||||||
assert member.overdue_count == 0
|
assert member.overdue_count == 0
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns 0 for member without cycles" do
|
test "returns 0 for member without cycles", %{actor: actor} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||||
|
|
||||||
member = Ash.load!(member, :overdue_count)
|
member = Ash.load!(member, :overdue_count, actor: actor)
|
||||||
assert member.overdue_count == 0
|
assert member.overdue_count == 0
|
||||||
end
|
end
|
||||||
|
|
||||||
test "counts overdue cycles for monthly interval" do
|
test "counts overdue cycles for monthly interval", %{actor: actor} do
|
||||||
fee_type = create_fee_type(%{interval: :monthly})
|
fee_type = create_fee_type(%{interval: :monthly}, actor)
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||||
|
|
||||||
today = Date.utc_today()
|
today = Date.utc_today()
|
||||||
|
|
||||||
|
|
@ -279,78 +354,125 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
|
||||||
last_month_start = Date.add(today, -32) |> CalendarCycles.calculate_cycle_start(:monthly)
|
last_month_start = Date.add(today, -32) |> CalendarCycles.calculate_cycle_start(:monthly)
|
||||||
current_month_start = CalendarCycles.calculate_cycle_start(today, :monthly)
|
current_month_start = CalendarCycles.calculate_cycle_start(today, :monthly)
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
create_cycle(
|
||||||
cycle_start: two_months_ago_start,
|
member,
|
||||||
status: :unpaid
|
fee_type,
|
||||||
})
|
%{
|
||||||
|
cycle_start: two_months_ago_start,
|
||||||
|
status: :unpaid
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
create_cycle(
|
||||||
cycle_start: last_month_start,
|
member,
|
||||||
status: :paid
|
fee_type,
|
||||||
})
|
%{
|
||||||
|
cycle_start: last_month_start,
|
||||||
|
status: :paid
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
create_cycle(
|
||||||
cycle_start: current_month_start,
|
member,
|
||||||
status: :unpaid
|
fee_type,
|
||||||
})
|
%{
|
||||||
|
cycle_start: current_month_start,
|
||||||
|
status: :unpaid
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
member = Ash.load!(member, :overdue_count)
|
member = Ash.load!(member, :overdue_count, actor: actor)
|
||||||
# Should only count two_months_ago (unpaid and ended)
|
# Should only count two_months_ago (unpaid and ended)
|
||||||
assert member.overdue_count == 1
|
assert member.overdue_count == 1
|
||||||
end
|
end
|
||||||
|
|
||||||
test "counts multiple overdue cycles" do
|
test "counts multiple overdue cycles", %{actor: actor} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||||
|
|
||||||
# Create multiple unpaid, ended cycles
|
# Create multiple unpaid, ended cycles
|
||||||
create_cycle(member, fee_type, %{
|
create_cycle(
|
||||||
cycle_start: ~D[2020-01-01],
|
member,
|
||||||
status: :unpaid
|
fee_type,
|
||||||
})
|
%{
|
||||||
|
cycle_start: ~D[2020-01-01],
|
||||||
|
status: :unpaid
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
create_cycle(
|
||||||
cycle_start: ~D[2021-01-01],
|
member,
|
||||||
status: :unpaid
|
fee_type,
|
||||||
})
|
%{
|
||||||
|
cycle_start: ~D[2021-01-01],
|
||||||
|
status: :unpaid
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
create_cycle(
|
||||||
cycle_start: ~D[2022-01-01],
|
member,
|
||||||
status: :unpaid
|
fee_type,
|
||||||
})
|
%{
|
||||||
|
cycle_start: ~D[2022-01-01],
|
||||||
|
status: :unpaid
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
member = Ash.load!(member, :overdue_count)
|
member = Ash.load!(member, :overdue_count, actor: actor)
|
||||||
assert member.overdue_count == 3
|
assert member.overdue_count == 3
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "calculations with multiple cycles" do
|
describe "calculations with multiple cycles" do
|
||||||
test "all calculations work correctly with multiple cycles" do
|
test "all calculations work correctly with multiple cycles", %{actor: actor} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||||
|
|
||||||
today = Date.utc_today()
|
today = Date.utc_today()
|
||||||
|
|
||||||
# Create cycles: 2022 (unpaid, ended), 2023 (paid, ended), 2024 (unpaid, current)
|
# Create cycles: 2022 (unpaid, ended), 2023 (paid, ended), 2024 (unpaid, current)
|
||||||
create_cycle(member, fee_type, %{
|
create_cycle(
|
||||||
cycle_start: ~D[2022-01-01],
|
member,
|
||||||
status: :unpaid
|
fee_type,
|
||||||
})
|
%{
|
||||||
|
cycle_start: ~D[2022-01-01],
|
||||||
|
status: :unpaid
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
create_cycle(
|
||||||
cycle_start: ~D[2023-01-01],
|
member,
|
||||||
status: :paid
|
fee_type,
|
||||||
})
|
%{
|
||||||
|
cycle_start: ~D[2023-01-01],
|
||||||
|
status: :paid
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
create_cycle(
|
||||||
cycle_start: cycle_start,
|
member,
|
||||||
status: :unpaid
|
fee_type,
|
||||||
})
|
%{
|
||||||
|
cycle_start: cycle_start,
|
||||||
|
status: :unpaid
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
member =
|
member =
|
||||||
Ash.load!(member, [:current_cycle_status, :last_cycle_status, :overdue_count])
|
Ash.load!(member, [:current_cycle_status, :last_cycle_status, :overdue_count],
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
assert member.current_cycle_status == :unpaid
|
assert member.current_cycle_status == :unpaid
|
||||||
assert member.last_cycle_status == :paid
|
assert member.last_cycle_status == :paid
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,11 @@ defmodule Mv.Membership.MemberEmailSyncTest do
|
||||||
alias Mv.Accounts
|
alias Mv.Accounts
|
||||||
alias Mv.Membership
|
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
|
describe "Member email synchronization to linked User" do
|
||||||
@valid_user_attrs %{
|
@valid_user_attrs %{
|
||||||
email: "user@example.com"
|
email: "user@example.com"
|
||||||
|
|
@ -19,108 +24,119 @@ defmodule Mv.Membership.MemberEmailSyncTest do
|
||||||
email: "member@example.com"
|
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
|
# 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"
|
assert to_string(user.email) == "user@example.com"
|
||||||
|
|
||||||
# Create a member linked to the user
|
# Create a member linked to the user
|
||||||
{:ok, member} =
|
{: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
|
# 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"
|
assert member_after_create.email == "user@example.com"
|
||||||
|
|
||||||
# Update member email
|
# Update member email
|
||||||
{:ok, updated_member} =
|
{: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"
|
assert updated_member.email == "newmember@example.com"
|
||||||
|
|
||||||
# Verify user email was also updated
|
# 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"
|
assert to_string(synced_user.email) == "newmember@example.com"
|
||||||
end
|
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
|
# 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"
|
assert to_string(user.email) == "user@example.com"
|
||||||
|
|
||||||
# Create a member linked to this user
|
# Create a member linked to this user
|
||||||
{:ok, member} =
|
{: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)
|
# Member should have been created with user's email (user is source of truth)
|
||||||
assert member.email == "user@example.com"
|
assert member.email == "user@example.com"
|
||||||
|
|
||||||
# Verify the link
|
# 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
|
assert loaded_member.user.id == user.id
|
||||||
end
|
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
|
# 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 to_string(user.email) == "user@example.com"
|
||||||
|
|
||||||
# Create a standalone member
|
# 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"
|
assert member.email == "member@example.com"
|
||||||
|
|
||||||
# Link the member to the user
|
# 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
|
# 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
|
assert loaded_member.user.id == user.id
|
||||||
|
|
||||||
# Verify member email was overridden with user email
|
# Verify member email was overridden with user email
|
||||||
assert loaded_member.email == "user@example.com"
|
assert loaded_member.email == "user@example.com"
|
||||||
end
|
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
|
# 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"
|
assert member.email == "member@example.com"
|
||||||
|
|
||||||
# Load to verify no user link
|
# 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
|
assert loaded_member.user == nil
|
||||||
|
|
||||||
# Update member email - should work fine without error
|
# Update member email - should work fine without error
|
||||||
{:ok, updated_member} =
|
{: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"
|
assert updated_member.email == "newemail@example.com"
|
||||||
end
|
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
|
# 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
|
# Create member linked to user
|
||||||
{:ok, member} =
|
{: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
|
# 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"
|
assert synced_member.email == "user@example.com"
|
||||||
|
|
||||||
# Verify link exists
|
# 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
|
assert loaded_member.user != nil
|
||||||
|
|
||||||
# Unlink member from user
|
# 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
|
# 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
|
assert loaded_unlinked.user == nil
|
||||||
|
|
||||||
# User email should remain unchanged after unlinking
|
# 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"
|
assert to_string(user_after_unlink.email) == "user@example.com"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,23 @@ defmodule Mv.Membership.MemberFuzzySearchLinkingTest do
|
||||||
alias Mv.Accounts
|
alias Mv.Accounts
|
||||||
alias Mv.Membership
|
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
|
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
|
# Create member with specific name
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Jonathan",
|
%{
|
||||||
last_name: "Smith",
|
first_name: "Jonathan",
|
||||||
email: "jonathan@example.com"
|
last_name: "Smith",
|
||||||
})
|
email: "jonathan@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Search with typo
|
# Search with typo
|
||||||
query =
|
query =
|
||||||
|
|
@ -27,21 +35,24 @@ defmodule Mv.Membership.MemberFuzzySearchLinkingTest do
|
||||||
search_query: "Jonatan"
|
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
|
# Should find Jonathan despite typo
|
||||||
assert length(members) == 1
|
assert length(members) == 1
|
||||||
assert hd(members).id == member.id
|
assert hd(members).id == member.id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "finds member with partial match" do
|
test "finds member with partial match", %{actor: actor} do
|
||||||
# Create member
|
# Create member
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Alexander",
|
%{
|
||||||
last_name: "Williams",
|
first_name: "Alexander",
|
||||||
email: "alex@example.com"
|
last_name: "Williams",
|
||||||
})
|
email: "alex@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Search with partial
|
# Search with partial
|
||||||
query =
|
query =
|
||||||
|
|
@ -51,28 +62,34 @@ defmodule Mv.Membership.MemberFuzzySearchLinkingTest do
|
||||||
search_query: "Alex"
|
search_query: "Alex"
|
||||||
})
|
})
|
||||||
|
|
||||||
{:ok, members} = Ash.read(query, domain: Mv.Membership)
|
{:ok, members} = Ash.read(query, domain: Mv.Membership, actor: actor)
|
||||||
|
|
||||||
# Should find Alexander
|
# Should find Alexander
|
||||||
assert length(members) == 1
|
assert length(members) == 1
|
||||||
assert hd(members).id == member.id
|
assert hd(members).id == member.id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "email match overrides fuzzy search" do
|
test "email match overrides fuzzy search", %{actor: actor} do
|
||||||
# Create two members
|
# Create two members
|
||||||
{:ok, member1} =
|
{:ok, member1} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "John",
|
%{
|
||||||
last_name: "Doe",
|
first_name: "John",
|
||||||
email: "john@example.com"
|
last_name: "Doe",
|
||||||
})
|
email: "john@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, _member2} =
|
{:ok, _member2} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Jane",
|
%{
|
||||||
last_name: "Smith",
|
first_name: "Jane",
|
||||||
email: "jane@example.com"
|
last_name: "Smith",
|
||||||
})
|
email: "jane@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Search with user_email that matches member1, but search_query that would match member2
|
# Search with user_email that matches member1, but search_query that would match member2
|
||||||
query =
|
query =
|
||||||
|
|
@ -82,7 +99,7 @@ defmodule Mv.Membership.MemberFuzzySearchLinkingTest do
|
||||||
search_query: "Jane"
|
search_query: "Jane"
|
||||||
})
|
})
|
||||||
|
|
||||||
{:ok, members} = Ash.read(query, domain: Mv.Membership)
|
{:ok, members} = Ash.read(query, domain: Mv.Membership, actor: actor)
|
||||||
|
|
||||||
# Apply email filter
|
# Apply email filter
|
||||||
filtered_members = Mv.Membership.Member.filter_by_email_match(members, "john@example.com")
|
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
|
assert hd(filtered_members).id == member1.id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "limits to 10 results" do
|
test "limits to 10 results", %{actor: actor} do
|
||||||
# Create 15 members with similar names
|
# Create 15 members with similar names
|
||||||
for i <- 1..15 do
|
for i <- 1..15 do
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Test#{i}",
|
%{
|
||||||
last_name: "Member",
|
first_name: "Test#{i}",
|
||||||
email: "test#{i}@example.com"
|
last_name: "Member",
|
||||||
})
|
email: "test#{i}@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Search for "Test"
|
# Search for "Test"
|
||||||
|
|
@ -110,34 +130,43 @@ defmodule Mv.Membership.MemberFuzzySearchLinkingTest do
|
||||||
search_query: "Test"
|
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
|
# Should return max 10 members
|
||||||
assert length(members) == 10
|
assert length(members) == 10
|
||||||
end
|
end
|
||||||
|
|
||||||
test "excludes linked members" do
|
test "excludes linked members", %{actor: actor} do
|
||||||
# Create member and link to user
|
# Create member and link to user
|
||||||
{:ok, member1} =
|
{:ok, member1} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Linked",
|
%{
|
||||||
last_name: "Member",
|
first_name: "Linked",
|
||||||
email: "linked@example.com"
|
last_name: "Member",
|
||||||
})
|
email: "linked@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, _user} =
|
{:ok, _user} =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "user@example.com",
|
%{
|
||||||
member: %{id: member1.id}
|
email: "user@example.com",
|
||||||
})
|
member: %{id: member1.id}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Create unlinked member
|
# Create unlinked member
|
||||||
{:ok, member2} =
|
{:ok, member2} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Unlinked",
|
%{
|
||||||
last_name: "Member",
|
first_name: "Unlinked",
|
||||||
email: "unlinked@example.com"
|
last_name: "Member",
|
||||||
})
|
email: "unlinked@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Search for "Member"
|
# Search for "Member"
|
||||||
query =
|
query =
|
||||||
|
|
@ -147,7 +176,7 @@ defmodule Mv.Membership.MemberFuzzySearchLinkingTest do
|
||||||
search_query: "Member"
|
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
|
# Should only return unlinked member
|
||||||
member_ids = Enum.map(members, & &1.id)
|
member_ids = Enum.map(members, & &1.id)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Create required custom fields for different types
|
# Create required custom fields for different types
|
||||||
{:ok, required_string_field} =
|
{:ok, required_string_field} =
|
||||||
Membership.CustomField
|
Membership.CustomField
|
||||||
|
|
@ -22,7 +24,7 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
required: true
|
required: true
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, required_integer_field} =
|
{:ok, required_integer_field} =
|
||||||
Membership.CustomField
|
Membership.CustomField
|
||||||
|
|
@ -31,7 +33,7 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
|
||||||
value_type: :integer,
|
value_type: :integer,
|
||||||
required: true
|
required: true
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, required_boolean_field} =
|
{:ok, required_boolean_field} =
|
||||||
Membership.CustomField
|
Membership.CustomField
|
||||||
|
|
@ -40,7 +42,7 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
|
||||||
value_type: :boolean,
|
value_type: :boolean,
|
||||||
required: true
|
required: true
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, required_date_field} =
|
{:ok, required_date_field} =
|
||||||
Membership.CustomField
|
Membership.CustomField
|
||||||
|
|
@ -49,7 +51,7 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
|
||||||
value_type: :date,
|
value_type: :date,
|
||||||
required: true
|
required: true
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, required_email_field} =
|
{:ok, required_email_field} =
|
||||||
Membership.CustomField
|
Membership.CustomField
|
||||||
|
|
@ -58,7 +60,7 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
|
||||||
value_type: :email,
|
value_type: :email,
|
||||||
required: true
|
required: true
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, optional_field} =
|
{:ok, optional_field} =
|
||||||
Membership.CustomField
|
Membership.CustomField
|
||||||
|
|
@ -67,7 +69,7 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
required: false
|
required: false
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
required_string_field: required_string_field,
|
required_string_field: required_string_field,
|
||||||
|
|
@ -75,7 +77,8 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
|
||||||
required_boolean_field: required_boolean_field,
|
required_boolean_field: required_boolean_field,
|
||||||
required_date_field: required_date_field,
|
required_date_field: required_date_field,
|
||||||
required_email_field: required_email_field,
|
required_email_field: required_email_field,
|
||||||
optional_field: optional_field
|
optional_field: optional_field,
|
||||||
|
actor: system_actor
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -118,17 +121,23 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
|
||||||
email: "john@example.com"
|
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, [])
|
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) =~ "Required custom fields missing"
|
||||||
assert error_message(errors, :custom_field_values) =~ field.name
|
assert error_message(errors, :custom_field_values) =~ field.name
|
||||||
end
|
end
|
||||||
|
|
||||||
test "fails when required string custom field has nil value",
|
test "fails when required string custom field has nil value",
|
||||||
%{
|
%{
|
||||||
required_string_field: field
|
required_string_field: field,
|
||||||
|
actor: actor
|
||||||
} = context do
|
} = context do
|
||||||
# Start with all required fields having valid values
|
# Start with all required fields having valid values
|
||||||
custom_field_values =
|
custom_field_values =
|
||||||
|
|
@ -143,14 +152,17 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
|
||||||
|
|
||||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
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) =~ "Required custom fields missing"
|
||||||
assert error_message(errors, :custom_field_values) =~ field.name
|
assert error_message(errors, :custom_field_values) =~ field.name
|
||||||
end
|
end
|
||||||
|
|
||||||
test "fails when required string custom field has empty string value",
|
test "fails when required string custom field has empty string value",
|
||||||
%{
|
%{
|
||||||
required_string_field: field
|
required_string_field: field,
|
||||||
|
actor: actor
|
||||||
} = context do
|
} = context do
|
||||||
# Start with all required fields having valid values
|
# Start with all required fields having valid values
|
||||||
custom_field_values =
|
custom_field_values =
|
||||||
|
|
@ -165,14 +177,17 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
|
||||||
|
|
||||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
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) =~ "Required custom fields missing"
|
||||||
assert error_message(errors, :custom_field_values) =~ field.name
|
assert error_message(errors, :custom_field_values) =~ field.name
|
||||||
end
|
end
|
||||||
|
|
||||||
test "fails when required string custom field has whitespace-only value",
|
test "fails when required string custom field has whitespace-only value",
|
||||||
%{
|
%{
|
||||||
required_string_field: field
|
required_string_field: field,
|
||||||
|
actor: actor
|
||||||
} = context do
|
} = context do
|
||||||
# Start with all required fields having valid values
|
# Start with all required fields having valid values
|
||||||
custom_field_values =
|
custom_field_values =
|
||||||
|
|
@ -187,14 +202,17 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
|
||||||
|
|
||||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
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) =~ "Required custom fields missing"
|
||||||
assert error_message(errors, :custom_field_values) =~ field.name
|
assert error_message(errors, :custom_field_values) =~ field.name
|
||||||
end
|
end
|
||||||
|
|
||||||
test "succeeds when required string custom field has valid value",
|
test "succeeds when required string custom field has valid value",
|
||||||
%{
|
%{
|
||||||
required_string_field: field
|
required_string_field: field,
|
||||||
|
actor: actor
|
||||||
} = context do
|
} = context do
|
||||||
# Start with all required fields having valid values, then update the string field
|
# Start with all required fields having valid values, then update the string field
|
||||||
custom_field_values =
|
custom_field_values =
|
||||||
|
|
@ -209,12 +227,13 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
|
||||||
|
|
||||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
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
|
||||||
|
|
||||||
test "fails when required integer custom field has nil value",
|
test "fails when required integer custom field has nil value",
|
||||||
%{
|
%{
|
||||||
required_integer_field: field
|
required_integer_field: field,
|
||||||
|
actor: actor
|
||||||
} = context do
|
} = context do
|
||||||
custom_field_values =
|
custom_field_values =
|
||||||
all_required_custom_fields_with_defaults(context)
|
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)
|
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) =~ "Required custom fields missing"
|
||||||
assert error_message(errors, :custom_field_values) =~ field.name
|
assert error_message(errors, :custom_field_values) =~ field.name
|
||||||
end
|
end
|
||||||
|
|
||||||
test "fails when required integer custom field has empty string value",
|
test "fails when required integer custom field has empty string value",
|
||||||
%{
|
%{
|
||||||
required_integer_field: field
|
required_integer_field: field,
|
||||||
|
actor: actor
|
||||||
} = context do
|
} = context do
|
||||||
custom_field_values =
|
custom_field_values =
|
||||||
all_required_custom_fields_with_defaults(context)
|
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)
|
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) =~ "Required custom fields missing"
|
||||||
assert error_message(errors, :custom_field_values) =~ field.name
|
assert error_message(errors, :custom_field_values) =~ field.name
|
||||||
end
|
end
|
||||||
|
|
||||||
test "succeeds when required integer custom field has zero value",
|
test "succeeds when required integer custom field has zero value",
|
||||||
%{
|
%{
|
||||||
required_integer_field: _field
|
required_integer_field: _field,
|
||||||
|
actor: actor
|
||||||
} = context do
|
} = context do
|
||||||
custom_field_values = all_required_custom_fields_with_defaults(context)
|
custom_field_values = all_required_custom_fields_with_defaults(context)
|
||||||
|
|
||||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
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
|
||||||
|
|
||||||
test "succeeds when required integer custom field has positive value",
|
test "succeeds when required integer custom field has positive value",
|
||||||
%{
|
%{
|
||||||
required_integer_field: field
|
required_integer_field: field,
|
||||||
|
actor: actor
|
||||||
} = context do
|
} = context do
|
||||||
custom_field_values =
|
custom_field_values =
|
||||||
all_required_custom_fields_with_defaults(context)
|
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)
|
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
|
||||||
|
|
||||||
test "fails when required boolean custom field has nil value",
|
test "fails when required boolean custom field has nil value",
|
||||||
%{
|
%{
|
||||||
required_boolean_field: field
|
required_boolean_field: field,
|
||||||
|
actor: actor
|
||||||
} = context do
|
} = context do
|
||||||
custom_field_values =
|
custom_field_values =
|
||||||
all_required_custom_fields_with_defaults(context)
|
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)
|
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) =~ "Required custom fields missing"
|
||||||
assert error_message(errors, :custom_field_values) =~ field.name
|
assert error_message(errors, :custom_field_values) =~ field.name
|
||||||
end
|
end
|
||||||
|
|
||||||
test "succeeds when required boolean custom field has false value",
|
test "succeeds when required boolean custom field has false value",
|
||||||
%{
|
%{
|
||||||
required_boolean_field: _field
|
required_boolean_field: _field,
|
||||||
|
actor: actor
|
||||||
} = context do
|
} = context do
|
||||||
custom_field_values = all_required_custom_fields_with_defaults(context)
|
custom_field_values = all_required_custom_fields_with_defaults(context)
|
||||||
|
|
||||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
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
|
||||||
|
|
||||||
test "succeeds when required boolean custom field has true value",
|
test "succeeds when required boolean custom field has true value",
|
||||||
%{
|
%{
|
||||||
required_boolean_field: field
|
required_boolean_field: field,
|
||||||
|
actor: actor
|
||||||
} = context do
|
} = context do
|
||||||
custom_field_values =
|
custom_field_values =
|
||||||
all_required_custom_fields_with_defaults(context)
|
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)
|
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
|
||||||
|
|
||||||
test "fails when required date custom field has nil value",
|
test "fails when required date custom field has nil value",
|
||||||
%{
|
%{
|
||||||
required_date_field: field
|
required_date_field: field,
|
||||||
|
actor: actor
|
||||||
} = context do
|
} = context do
|
||||||
custom_field_values =
|
custom_field_values =
|
||||||
all_required_custom_fields_with_defaults(context)
|
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)
|
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) =~ "Required custom fields missing"
|
||||||
assert error_message(errors, :custom_field_values) =~ field.name
|
assert error_message(errors, :custom_field_values) =~ field.name
|
||||||
end
|
end
|
||||||
|
|
||||||
test "fails when required date custom field has empty string value",
|
test "fails when required date custom field has empty string value",
|
||||||
%{
|
%{
|
||||||
required_date_field: field
|
required_date_field: field,
|
||||||
|
actor: actor
|
||||||
} = context do
|
} = context do
|
||||||
custom_field_values =
|
custom_field_values =
|
||||||
all_required_custom_fields_with_defaults(context)
|
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)
|
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) =~ "Required custom fields missing"
|
||||||
assert error_message(errors, :custom_field_values) =~ field.name
|
assert error_message(errors, :custom_field_values) =~ field.name
|
||||||
end
|
end
|
||||||
|
|
||||||
test "succeeds when required date custom field has valid date value",
|
test "succeeds when required date custom field has valid date value",
|
||||||
%{
|
%{
|
||||||
required_date_field: _field
|
required_date_field: _field,
|
||||||
|
actor: actor
|
||||||
} = context do
|
} = context do
|
||||||
custom_field_values = all_required_custom_fields_with_defaults(context)
|
custom_field_values = all_required_custom_fields_with_defaults(context)
|
||||||
|
|
||||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
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
|
||||||
|
|
||||||
test "fails when required email custom field has nil value",
|
test "fails when required email custom field has nil value",
|
||||||
%{
|
%{
|
||||||
required_email_field: field
|
required_email_field: field,
|
||||||
|
actor: actor
|
||||||
} = context do
|
} = context do
|
||||||
custom_field_values =
|
custom_field_values =
|
||||||
all_required_custom_fields_with_defaults(context)
|
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)
|
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) =~ "Required custom fields missing"
|
||||||
assert error_message(errors, :custom_field_values) =~ field.name
|
assert error_message(errors, :custom_field_values) =~ field.name
|
||||||
end
|
end
|
||||||
|
|
||||||
test "fails when required email custom field has empty string value",
|
test "fails when required email custom field has empty string value",
|
||||||
%{
|
%{
|
||||||
required_email_field: field
|
required_email_field: field,
|
||||||
|
actor: actor
|
||||||
} = context do
|
} = context do
|
||||||
custom_field_values =
|
custom_field_values =
|
||||||
all_required_custom_fields_with_defaults(context)
|
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)
|
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) =~ "Required custom fields missing"
|
||||||
assert error_message(errors, :custom_field_values) =~ field.name
|
assert error_message(errors, :custom_field_values) =~ field.name
|
||||||
end
|
end
|
||||||
|
|
||||||
test "succeeds when required email custom field has valid email value",
|
test "succeeds when required email custom field has valid email value",
|
||||||
%{
|
%{
|
||||||
required_email_field: _field
|
required_email_field: _field,
|
||||||
|
actor: actor
|
||||||
} = context do
|
} = context do
|
||||||
custom_field_values = all_required_custom_fields_with_defaults(context)
|
custom_field_values = all_required_custom_fields_with_defaults(context)
|
||||||
|
|
||||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
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
|
||||||
|
|
||||||
test "succeeds when multiple required custom fields are provided",
|
test "succeeds when multiple required custom fields are provided",
|
||||||
%{
|
%{
|
||||||
required_string_field: string_field,
|
required_string_field: string_field,
|
||||||
required_integer_field: integer_field,
|
required_integer_field: integer_field,
|
||||||
required_boolean_field: boolean_field
|
required_boolean_field: boolean_field,
|
||||||
|
actor: actor
|
||||||
} = context do
|
} = context do
|
||||||
custom_field_values =
|
custom_field_values =
|
||||||
all_required_custom_fields_with_defaults(context)
|
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)
|
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
|
||||||
|
|
||||||
test "fails when one of multiple required custom fields is missing",
|
test "fails when one of multiple required custom fields is missing",
|
||||||
%{
|
%{
|
||||||
required_string_field: string_field,
|
required_string_field: string_field,
|
||||||
required_integer_field: integer_field
|
required_integer_field: integer_field,
|
||||||
|
actor: actor
|
||||||
} = context do
|
} = context do
|
||||||
# Provide only string field, missing integer, boolean, and date
|
# Provide only string field, missing integer, boolean, and date
|
||||||
custom_field_values =
|
custom_field_values =
|
||||||
|
|
@ -487,22 +534,24 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
|
||||||
|
|
||||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
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) =~ "Required custom fields missing"
|
||||||
assert error_message(errors, :custom_field_values) =~ integer_field.name
|
assert error_message(errors, :custom_field_values) =~ integer_field.name
|
||||||
end
|
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
|
# Provide all required fields, but no optional field
|
||||||
custom_field_values = all_required_custom_fields_with_defaults(context)
|
custom_field_values = all_required_custom_fields_with_defaults(context)
|
||||||
|
|
||||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
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
|
||||||
|
|
||||||
test "succeeds when optional custom field has nil value",
|
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
|
# Provide all required fields plus optional field with nil
|
||||||
custom_field_values =
|
custom_field_values =
|
||||||
all_required_custom_fields_with_defaults(context) ++
|
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)
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "update_member with required custom fields" do
|
describe "update_member with required custom fields" do
|
||||||
test "fails when removing a required custom field value",
|
test "fails when removing a required custom field value",
|
||||||
%{
|
%{
|
||||||
required_string_field: field
|
required_string_field: field,
|
||||||
|
actor: actor
|
||||||
} = context do
|
} = context do
|
||||||
# Create member with all required custom fields
|
# Create member with all required custom fields
|
||||||
custom_field_values = all_required_custom_fields_with_defaults(context)
|
custom_field_values = all_required_custom_fields_with_defaults(context)
|
||||||
|
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "John",
|
%{
|
||||||
last_name: "Doe",
|
first_name: "John",
|
||||||
email: "john@example.com",
|
last_name: "Doe",
|
||||||
custom_field_values: custom_field_values
|
email: "john@example.com",
|
||||||
})
|
custom_field_values: custom_field_values
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Try to update without the required custom field
|
# Try to update without the required custom field
|
||||||
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
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) =~ "Required custom fields missing"
|
||||||
assert error_message(errors, :custom_field_values) =~ field.name
|
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",
|
test "fails when setting required custom field value to empty",
|
||||||
%{
|
%{
|
||||||
required_string_field: field
|
required_string_field: field,
|
||||||
|
actor: actor
|
||||||
} = context do
|
} = context do
|
||||||
# Create member with all required custom fields
|
# Create member with all required custom fields
|
||||||
custom_field_values = all_required_custom_fields_with_defaults(context)
|
custom_field_values = all_required_custom_fields_with_defaults(context)
|
||||||
|
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "John",
|
%{
|
||||||
last_name: "Doe",
|
first_name: "John",
|
||||||
email: "john@example.com",
|
last_name: "Doe",
|
||||||
custom_field_values: custom_field_values
|
email: "john@example.com",
|
||||||
})
|
custom_field_values: custom_field_values
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Try to update with empty value for the string field
|
# Try to update with empty value for the string field
|
||||||
updated_custom_field_values =
|
updated_custom_field_values =
|
||||||
|
|
@ -570,9 +627,13 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
|
||||||
end)
|
end)
|
||||||
|
|
||||||
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
||||||
Membership.update_member(member, %{
|
Membership.update_member(
|
||||||
custom_field_values: updated_custom_field_values
|
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) =~ "Required custom fields missing"
|
||||||
assert error_message(errors, :custom_field_values) =~ field.name
|
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",
|
test "succeeds when updating required custom field to valid value",
|
||||||
%{
|
%{
|
||||||
required_string_field: field
|
required_string_field: field,
|
||||||
|
actor: actor
|
||||||
} = context do
|
} = context do
|
||||||
# Create member with all required custom fields
|
# Create member with all required custom fields
|
||||||
custom_field_values = all_required_custom_fields_with_defaults(context)
|
custom_field_values = all_required_custom_fields_with_defaults(context)
|
||||||
|
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "John",
|
%{
|
||||||
last_name: "Doe",
|
first_name: "John",
|
||||||
email: "john@example.com",
|
last_name: "Doe",
|
||||||
custom_field_values: custom_field_values
|
email: "john@example.com",
|
||||||
})
|
custom_field_values: custom_field_values
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Load existing custom field values to get their IDs
|
# 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
|
# Update with new valid value for the string field, using existing IDs
|
||||||
updated_custom_field_values =
|
updated_custom_field_values =
|
||||||
|
|
@ -620,9 +685,13 @@ defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
|
||||||
end)
|
end)
|
||||||
|
|
||||||
assert {:ok, _updated_member} =
|
assert {:ok, _updated_member} =
|
||||||
Membership.update_member(member, %{
|
Membership.update_member(
|
||||||
custom_field_values: updated_custom_field_values
|
member,
|
||||||
})
|
%{
|
||||||
|
custom_field_values: updated_custom_field_values
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Create test members
|
# Create test members
|
||||||
{:ok, member1} =
|
{:ok, member1} =
|
||||||
Member
|
Member
|
||||||
|
|
@ -18,7 +20,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
last_name: "Anderson",
|
last_name: "Anderson",
|
||||||
email: "alice@example.com"
|
email: "alice@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, member2} =
|
{:ok, member2} =
|
||||||
Member
|
Member
|
||||||
|
|
@ -27,7 +29,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
last_name: "Brown",
|
last_name: "Brown",
|
||||||
email: "bob@example.com"
|
email: "bob@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, member3} =
|
{:ok, member3} =
|
||||||
Member
|
Member
|
||||||
|
|
@ -36,7 +38,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
last_name: "Clark",
|
last_name: "Clark",
|
||||||
email: "charlie@example.com"
|
email: "charlie@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Create custom fields for different types
|
# Create custom fields for different types
|
||||||
{:ok, string_field} =
|
{:ok, string_field} =
|
||||||
|
|
@ -45,7 +47,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
name: "membership_number",
|
name: "membership_number",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, integer_field} =
|
{:ok, integer_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|
|
@ -53,7 +55,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
name: "member_id_number",
|
name: "member_id_number",
|
||||||
value_type: :integer
|
value_type: :integer
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, email_field} =
|
{:ok, email_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|
|
@ -61,7 +63,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
name: "secondary_email",
|
name: "secondary_email",
|
||||||
value_type: :email
|
value_type: :email
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, date_field} =
|
{:ok, date_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|
|
@ -69,7 +71,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
name: "birthday",
|
name: "birthday",
|
||||||
value_type: :date
|
value_type: :date
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, boolean_field} =
|
{:ok, boolean_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|
|
@ -77,7 +79,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
name: "newsletter",
|
name: "newsletter",
|
||||||
value_type: :boolean
|
value_type: :boolean
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
member1: member1,
|
member1: member1,
|
||||||
|
|
@ -87,12 +89,14 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
integer_field: integer_field,
|
integer_field: integer_field,
|
||||||
email_field: email_field,
|
email_field: email_field,
|
||||||
date_field: date_field,
|
date_field: date_field,
|
||||||
boolean_field: boolean_field
|
boolean_field: boolean_field,
|
||||||
|
system_actor: system_actor
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "search with custom field values" do
|
describe "search with custom field values" do
|
||||||
test "finds member by string custom field value", %{
|
test "finds member by string custom field value", %{
|
||||||
|
system_actor: system_actor,
|
||||||
member1: member1,
|
member1: member1,
|
||||||
string_field: string_field
|
string_field: string_field
|
||||||
} do
|
} do
|
||||||
|
|
@ -104,25 +108,26 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
custom_field_id: string_field.id,
|
custom_field_id: string_field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "MEMBER12345"}
|
value: %{"_union_type" => "string", "_union_value" => "MEMBER12345"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Force search_vector update by reloading member
|
# Force search_vector update by reloading member
|
||||||
{:ok, _updated_member} =
|
{:ok, _updated_member} =
|
||||||
member1
|
member1
|
||||||
|> Ash.Changeset.for_update(:update_member, %{})
|
|> Ash.Changeset.for_update(:update_member, %{})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: system_actor)
|
||||||
|
|
||||||
# Search for the custom field value
|
# Search for the custom field value
|
||||||
results =
|
results =
|
||||||
Member
|
Member
|
||||||
|> Member.fuzzy_search(%{query: "MEMBER12345"})
|
|> Member.fuzzy_search(%{query: "MEMBER12345"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
assert length(results) == 1
|
assert length(results) == 1
|
||||||
assert List.first(results).id == member1.id
|
assert List.first(results).id == member1.id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "finds member by integer custom field value", %{
|
test "finds member by integer custom field value", %{
|
||||||
|
system_actor: system_actor,
|
||||||
member1: member1,
|
member1: member1,
|
||||||
integer_field: integer_field
|
integer_field: integer_field
|
||||||
} do
|
} do
|
||||||
|
|
@ -134,25 +139,26 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
custom_field_id: integer_field.id,
|
custom_field_id: integer_field.id,
|
||||||
value: %{"_union_type" => "integer", "_union_value" => 42_424}
|
value: %{"_union_type" => "integer", "_union_value" => 42_424}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Force search_vector update
|
# Force search_vector update
|
||||||
{:ok, _updated_member} =
|
{:ok, _updated_member} =
|
||||||
member1
|
member1
|
||||||
|> Ash.Changeset.for_update(:update_member, %{})
|
|> Ash.Changeset.for_update(:update_member, %{})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: system_actor)
|
||||||
|
|
||||||
# Search for the custom field value
|
# Search for the custom field value
|
||||||
results =
|
results =
|
||||||
Member
|
Member
|
||||||
|> Member.fuzzy_search(%{query: "42424"})
|
|> Member.fuzzy_search(%{query: "42424"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
assert length(results) == 1
|
assert length(results) == 1
|
||||||
assert List.first(results).id == member1.id
|
assert List.first(results).id == member1.id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "finds member by email custom field value", %{
|
test "finds member by email custom field value", %{
|
||||||
|
system_actor: system_actor,
|
||||||
member1: member1,
|
member1: member1,
|
||||||
email_field: email_field
|
email_field: email_field
|
||||||
} do
|
} do
|
||||||
|
|
@ -164,19 +170,19 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
custom_field_id: email_field.id,
|
custom_field_id: email_field.id,
|
||||||
value: %{"_union_type" => "email", "_union_value" => "alice.secondary@example.com"}
|
value: %{"_union_type" => "email", "_union_value" => "alice.secondary@example.com"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Force search_vector update
|
# Force search_vector update
|
||||||
{:ok, _updated_member} =
|
{:ok, _updated_member} =
|
||||||
member1
|
member1
|
||||||
|> Ash.Changeset.for_update(:update_member, %{})
|
|> 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)
|
# Search for partial custom field value (should work via FTS or custom field filter)
|
||||||
results =
|
results =
|
||||||
Member
|
Member
|
||||||
|> Member.fuzzy_search(%{query: "alice.secondary"})
|
|> Member.fuzzy_search(%{query: "alice.secondary"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
assert length(results) == 1
|
assert length(results) == 1
|
||||||
assert List.first(results).id == member1.id
|
assert List.first(results).id == member1.id
|
||||||
|
|
@ -185,7 +191,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
results_full =
|
results_full =
|
||||||
Member
|
Member
|
||||||
|> Member.fuzzy_search(%{query: "alice.secondary@example.com"})
|
|> Member.fuzzy_search(%{query: "alice.secondary@example.com"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
assert length(results_full) == 1
|
assert length(results_full) == 1
|
||||||
assert List.first(results_full).id == member1.id
|
assert List.first(results_full).id == member1.id
|
||||||
|
|
@ -195,7 +201,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
results_domain =
|
results_domain =
|
||||||
Member
|
Member
|
||||||
|> Member.fuzzy_search(%{query: "example.com"})
|
|> 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)
|
# Verify that member1 is in the results (may have other members too)
|
||||||
ids = Enum.map(results_domain, & &1.id)
|
ids = Enum.map(results_domain, & &1.id)
|
||||||
|
|
@ -203,6 +209,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "finds member by date custom field value", %{
|
test "finds member by date custom field value", %{
|
||||||
|
system_actor: system_actor,
|
||||||
member1: member1,
|
member1: member1,
|
||||||
date_field: date_field
|
date_field: date_field
|
||||||
} do
|
} do
|
||||||
|
|
@ -214,25 +221,26 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
custom_field_id: date_field.id,
|
custom_field_id: date_field.id,
|
||||||
value: %{"_union_type" => "date", "_union_value" => ~D[1990-05-15]}
|
value: %{"_union_type" => "date", "_union_value" => ~D[1990-05-15]}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Force search_vector update
|
# Force search_vector update
|
||||||
{:ok, _updated_member} =
|
{:ok, _updated_member} =
|
||||||
member1
|
member1
|
||||||
|> Ash.Changeset.for_update(:update_member, %{})
|
|> 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)
|
# Search for the custom field value (date is stored as text in search_vector)
|
||||||
results =
|
results =
|
||||||
Member
|
Member
|
||||||
|> Member.fuzzy_search(%{query: "1990-05-15"})
|
|> Member.fuzzy_search(%{query: "1990-05-15"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
assert length(results) == 1
|
assert length(results) == 1
|
||||||
assert List.first(results).id == member1.id
|
assert List.first(results).id == member1.id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "finds member by boolean custom field value", %{
|
test "finds member by boolean custom field value", %{
|
||||||
|
system_actor: system_actor,
|
||||||
member1: member1,
|
member1: member1,
|
||||||
boolean_field: boolean_field
|
boolean_field: boolean_field
|
||||||
} do
|
} do
|
||||||
|
|
@ -244,25 +252,26 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
custom_field_id: boolean_field.id,
|
custom_field_id: boolean_field.id,
|
||||||
value: %{"_union_type" => "boolean", "_union_value" => true}
|
value: %{"_union_type" => "boolean", "_union_value" => true}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Force search_vector update
|
# Force search_vector update
|
||||||
{:ok, _updated_member} =
|
{:ok, _updated_member} =
|
||||||
member1
|
member1
|
||||||
|> Ash.Changeset.for_update(:update_member, %{})
|
|> 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)
|
# Search for the custom field value (boolean is stored as "true" or "false" text)
|
||||||
results =
|
results =
|
||||||
Member
|
Member
|
||||||
|> Member.fuzzy_search(%{query: "true"})
|
|> 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
|
# 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)
|
assert Enum.any?(results, fn m -> m.id == member1.id end)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "custom field value update triggers search_vector update", %{
|
test "custom field value update triggers search_vector update", %{
|
||||||
|
system_actor: system_actor,
|
||||||
member1: member1,
|
member1: member1,
|
||||||
string_field: string_field
|
string_field: string_field
|
||||||
} do
|
} do
|
||||||
|
|
@ -274,13 +283,13 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
custom_field_id: string_field.id,
|
custom_field_id: string_field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "OLDVALUE"}
|
value: %{"_union_type" => "string", "_union_value" => "OLDVALUE"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Force search_vector update
|
# Force search_vector update
|
||||||
{:ok, _updated_member} =
|
{:ok, _updated_member} =
|
||||||
member1
|
member1
|
||||||
|> Ash.Changeset.for_update(:update_member, %{})
|
|> Ash.Changeset.for_update(:update_member, %{})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: system_actor)
|
||||||
|
|
||||||
# Update custom field value
|
# Update custom field value
|
||||||
{:ok, _updated_cfv} =
|
{:ok, _updated_cfv} =
|
||||||
|
|
@ -288,13 +297,13 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
|> Ash.Changeset.for_update(:update, %{
|
|> Ash.Changeset.for_update(:update, %{
|
||||||
value: %{"_union_type" => "string", "_union_value" => "NEWVALUE123"}
|
value: %{"_union_type" => "string", "_union_value" => "NEWVALUE123"}
|
||||||
})
|
})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: system_actor)
|
||||||
|
|
||||||
# Search for the new value
|
# Search for the new value
|
||||||
results =
|
results =
|
||||||
Member
|
Member
|
||||||
|> Member.fuzzy_search(%{query: "NEWVALUE123"})
|
|> Member.fuzzy_search(%{query: "NEWVALUE123"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
assert length(results) == 1
|
assert length(results) == 1
|
||||||
assert List.first(results).id == member1.id
|
assert List.first(results).id == member1.id
|
||||||
|
|
@ -303,12 +312,13 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
old_results =
|
old_results =
|
||||||
Member
|
Member
|
||||||
|> Member.fuzzy_search(%{query: "OLDVALUE"})
|
|> Member.fuzzy_search(%{query: "OLDVALUE"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
refute Enum.any?(old_results, fn m -> m.id == member1.id end)
|
refute Enum.any?(old_results, fn m -> m.id == member1.id end)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "custom field value delete triggers search_vector update", %{
|
test "custom field value delete triggers search_vector update", %{
|
||||||
|
system_actor: system_actor,
|
||||||
member1: member1,
|
member1: member1,
|
||||||
string_field: string_field
|
string_field: string_field
|
||||||
} do
|
} do
|
||||||
|
|
@ -320,19 +330,19 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
custom_field_id: string_field.id,
|
custom_field_id: string_field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "TOBEDELETED"}
|
value: %{"_union_type" => "string", "_union_value" => "TOBEDELETED"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Force search_vector update
|
# Force search_vector update
|
||||||
{:ok, _updated_member} =
|
{:ok, _updated_member} =
|
||||||
member1
|
member1
|
||||||
|> Ash.Changeset.for_update(:update_member, %{})
|
|> Ash.Changeset.for_update(:update_member, %{})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: system_actor)
|
||||||
|
|
||||||
# Verify it's searchable
|
# Verify it's searchable
|
||||||
results =
|
results =
|
||||||
Member
|
Member
|
||||||
|> Member.fuzzy_search(%{query: "TOBEDELETED"})
|
|> Member.fuzzy_search(%{query: "TOBEDELETED"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
assert length(results) == 1
|
assert length(results) == 1
|
||||||
assert List.first(results).id == member1.id
|
assert List.first(results).id == member1.id
|
||||||
|
|
@ -344,12 +354,13 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
deleted_results =
|
deleted_results =
|
||||||
Member
|
Member
|
||||||
|> Member.fuzzy_search(%{query: "TOBEDELETED"})
|
|> Member.fuzzy_search(%{query: "TOBEDELETED"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
refute Enum.any?(deleted_results, fn m -> m.id == member1.id end)
|
refute Enum.any?(deleted_results, fn m -> m.id == member1.id end)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "custom field value create triggers search_vector update", %{
|
test "custom field value create triggers search_vector update", %{
|
||||||
|
system_actor: system_actor,
|
||||||
member1: member1,
|
member1: member1,
|
||||||
string_field: string_field
|
string_field: string_field
|
||||||
} do
|
} do
|
||||||
|
|
@ -361,19 +372,20 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
custom_field_id: string_field.id,
|
custom_field_id: string_field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "AUTOUPDATE"}
|
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)
|
# Search should find it immediately (trigger should have updated search_vector)
|
||||||
results =
|
results =
|
||||||
Member
|
Member
|
||||||
|> Member.fuzzy_search(%{query: "AUTOUPDATE"})
|
|> Member.fuzzy_search(%{query: "AUTOUPDATE"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
assert length(results) == 1
|
assert length(results) == 1
|
||||||
assert List.first(results).id == member1.id
|
assert List.first(results).id == member1.id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "member update includes custom field values in search_vector", %{
|
test "member update includes custom field values in search_vector", %{
|
||||||
|
system_actor: system_actor,
|
||||||
member1: member1,
|
member1: member1,
|
||||||
string_field: string_field
|
string_field: string_field
|
||||||
} do
|
} do
|
||||||
|
|
@ -385,25 +397,26 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
custom_field_id: string_field.id,
|
custom_field_id: string_field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "MEMBERUPDATE"}
|
value: %{"_union_type" => "string", "_union_value" => "MEMBERUPDATE"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Update member (should trigger search_vector update including custom fields)
|
# Update member (should trigger search_vector update including custom fields)
|
||||||
{:ok, _updated_member} =
|
{:ok, _updated_member} =
|
||||||
member1
|
member1
|
||||||
|> Ash.Changeset.for_update(:update_member, %{notes: "Updated notes"})
|
|> Ash.Changeset.for_update(:update_member, %{notes: "Updated notes"})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: system_actor)
|
||||||
|
|
||||||
# Search should find the custom field value
|
# Search should find the custom field value
|
||||||
results =
|
results =
|
||||||
Member
|
Member
|
||||||
|> Member.fuzzy_search(%{query: "MEMBERUPDATE"})
|
|> Member.fuzzy_search(%{query: "MEMBERUPDATE"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
assert length(results) == 1
|
assert length(results) == 1
|
||||||
assert List.first(results).id == member1.id
|
assert List.first(results).id == member1.id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "multiple custom field values are all searchable", %{
|
test "multiple custom field values are all searchable", %{
|
||||||
|
system_actor: system_actor,
|
||||||
member1: member1,
|
member1: member1,
|
||||||
string_field: string_field,
|
string_field: string_field,
|
||||||
integer_field: integer_field,
|
integer_field: integer_field,
|
||||||
|
|
@ -417,7 +430,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
custom_field_id: string_field.id,
|
custom_field_id: string_field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "MULTI1"}
|
value: %{"_union_type" => "string", "_union_value" => "MULTI1"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, _cfv2} =
|
{:ok, _cfv2} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|
|
@ -426,7 +439,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
custom_field_id: integer_field.id,
|
custom_field_id: integer_field.id,
|
||||||
value: %{"_union_type" => "integer", "_union_value" => 99_999}
|
value: %{"_union_type" => "integer", "_union_value" => 99_999}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, _cfv3} =
|
{:ok, _cfv3} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|
|
@ -435,38 +448,39 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
custom_field_id: email_field.id,
|
custom_field_id: email_field.id,
|
||||||
value: %{"_union_type" => "email", "_union_value" => "multi@test.com"}
|
value: %{"_union_type" => "email", "_union_value" => "multi@test.com"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Force search_vector update
|
# Force search_vector update
|
||||||
{:ok, _updated_member} =
|
{:ok, _updated_member} =
|
||||||
member1
|
member1
|
||||||
|> Ash.Changeset.for_update(:update_member, %{})
|
|> Ash.Changeset.for_update(:update_member, %{})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: system_actor)
|
||||||
|
|
||||||
# All values should be searchable
|
# All values should be searchable
|
||||||
results1 =
|
results1 =
|
||||||
Member
|
Member
|
||||||
|> Member.fuzzy_search(%{query: "MULTI1"})
|
|> Member.fuzzy_search(%{query: "MULTI1"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
assert Enum.any?(results1, fn m -> m.id == member1.id end)
|
assert Enum.any?(results1, fn m -> m.id == member1.id end)
|
||||||
|
|
||||||
results2 =
|
results2 =
|
||||||
Member
|
Member
|
||||||
|> Member.fuzzy_search(%{query: "99999"})
|
|> Member.fuzzy_search(%{query: "99999"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
assert Enum.any?(results2, fn m -> m.id == member1.id end)
|
assert Enum.any?(results2, fn m -> m.id == member1.id end)
|
||||||
|
|
||||||
results3 =
|
results3 =
|
||||||
Member
|
Member
|
||||||
|> Member.fuzzy_search(%{query: "multi@test.com"})
|
|> 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)
|
assert Enum.any?(results3, fn m -> m.id == member1.id end)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "finds member by custom field value with numbers in text field (e.g. phone number)", %{
|
test "finds member by custom field value with numbers in text field (e.g. phone number)", %{
|
||||||
|
system_actor: system_actor,
|
||||||
member1: member1,
|
member1: member1,
|
||||||
string_field: string_field
|
string_field: string_field
|
||||||
} do
|
} do
|
||||||
|
|
@ -478,19 +492,19 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
custom_field_id: string_field.id,
|
custom_field_id: string_field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "M-123-456"}
|
value: %{"_union_type" => "string", "_union_value" => "M-123-456"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Force search_vector update
|
# Force search_vector update
|
||||||
{:ok, _updated_member} =
|
{:ok, _updated_member} =
|
||||||
member1
|
member1
|
||||||
|> Ash.Changeset.for_update(:update_member, %{})
|
|> Ash.Changeset.for_update(:update_member, %{})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: system_actor)
|
||||||
|
|
||||||
# Search for full value (should work via search_vector)
|
# Search for full value (should work via search_vector)
|
||||||
results_full =
|
results_full =
|
||||||
Member
|
Member
|
||||||
|> Member.fuzzy_search(%{query: "M-123-456"})
|
|> 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),
|
assert Enum.any?(results_full, fn m -> m.id == member1.id end),
|
||||||
"Full value search should find member via search_vector"
|
"Full value search should find member via search_vector"
|
||||||
|
|
@ -501,6 +515,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "finds member by phone number in Emergency Contact custom field", %{
|
test "finds member by phone number in Emergency Contact custom field", %{
|
||||||
|
system_actor: system_actor,
|
||||||
member1: member1
|
member1: member1
|
||||||
} do
|
} do
|
||||||
# Create Emergency Contact custom field
|
# Create Emergency Contact custom field
|
||||||
|
|
@ -510,7 +525,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
name: "Emergency Contact",
|
name: "Emergency Contact",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Create custom field value with phone number
|
# Create custom field value with phone number
|
||||||
phone_number = "+49 123 456789"
|
phone_number = "+49 123 456789"
|
||||||
|
|
@ -522,19 +537,19 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
custom_field_id: emergency_contact_field.id,
|
custom_field_id: emergency_contact_field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => phone_number}
|
value: %{"_union_type" => "string", "_union_value" => phone_number}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Force search_vector update
|
# Force search_vector update
|
||||||
{:ok, _updated_member} =
|
{:ok, _updated_member} =
|
||||||
member1
|
member1
|
||||||
|> Ash.Changeset.for_update(:update_member, %{})
|
|> Ash.Changeset.for_update(:update_member, %{})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: system_actor)
|
||||||
|
|
||||||
# Search for full phone number (should work via search_vector)
|
# Search for full phone number (should work via search_vector)
|
||||||
results_full =
|
results_full =
|
||||||
Member
|
Member
|
||||||
|> Member.fuzzy_search(%{query: phone_number})
|
|> 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),
|
assert Enum.any?(results_full, fn m -> m.id == member1.id end),
|
||||||
"Full phone number search should find member via search_vector"
|
"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
|
describe "custom field substring search (ILIKE)" do
|
||||||
test "finds member by prefix of custom field value", %{
|
test "finds member by prefix of custom field value", %{
|
||||||
|
system_actor: system_actor,
|
||||||
member1: member1,
|
member1: member1,
|
||||||
string_field: string_field
|
string_field: string_field
|
||||||
} do
|
} do
|
||||||
|
|
@ -558,14 +574,14 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
custom_field_id: string_field.id,
|
custom_field_id: string_field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "Premium"}
|
value: %{"_union_type" => "string", "_union_value" => "Premium"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Test prefix searches - should all find the member
|
# Test prefix searches - should all find the member
|
||||||
for prefix <- ["Premium", "Premiu", "Premi", "Prem", "Pre"] do
|
for prefix <- ["Premium", "Premiu", "Premi", "Prem", "Pre"] do
|
||||||
results =
|
results =
|
||||||
Member
|
Member
|
||||||
|> Member.fuzzy_search(%{query: prefix})
|
|> Member.fuzzy_search(%{query: prefix})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
assert Enum.any?(results, fn m -> m.id == member1.id end),
|
assert Enum.any?(results, fn m -> m.id == member1.id end),
|
||||||
"Prefix '#{prefix}' should find member with custom field 'Premium'"
|
"Prefix '#{prefix}' should find member with custom field 'Premium'"
|
||||||
|
|
@ -573,6 +589,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "custom field search is case-insensitive", %{
|
test "custom field search is case-insensitive", %{
|
||||||
|
system_actor: system_actor,
|
||||||
member1: member1,
|
member1: member1,
|
||||||
string_field: string_field
|
string_field: string_field
|
||||||
} do
|
} do
|
||||||
|
|
@ -584,7 +601,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
custom_field_id: string_field.id,
|
custom_field_id: string_field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "GoldMember"}
|
value: %{"_union_type" => "string", "_union_value" => "GoldMember"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Test case variations - should all find the member
|
# Test case variations - should all find the member
|
||||||
for variant <- [
|
for variant <- [
|
||||||
|
|
@ -599,7 +616,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
results =
|
results =
|
||||||
Member
|
Member
|
||||||
|> Member.fuzzy_search(%{query: variant})
|
|> Member.fuzzy_search(%{query: variant})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
assert Enum.any?(results, fn m -> m.id == member1.id end),
|
assert Enum.any?(results, fn m -> m.id == member1.id end),
|
||||||
"Case variant '#{variant}' should find member with custom field 'GoldMember'"
|
"Case variant '#{variant}' should find member with custom field 'GoldMember'"
|
||||||
|
|
@ -607,6 +624,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "finds member by suffix/middle of custom field value", %{
|
test "finds member by suffix/middle of custom field value", %{
|
||||||
|
system_actor: system_actor,
|
||||||
member1: member1,
|
member1: member1,
|
||||||
string_field: string_field
|
string_field: string_field
|
||||||
} do
|
} do
|
||||||
|
|
@ -618,14 +636,14 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
custom_field_id: string_field.id,
|
custom_field_id: string_field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "ActiveMember"}
|
value: %{"_union_type" => "string", "_union_value" => "ActiveMember"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Test suffix and middle substring searches
|
# Test suffix and middle substring searches
|
||||||
for substring <- ["Member", "ember", "tiveMem", "ctive"] do
|
for substring <- ["Member", "ember", "tiveMem", "ctive"] do
|
||||||
results =
|
results =
|
||||||
Member
|
Member
|
||||||
|> Member.fuzzy_search(%{query: substring})
|
|> Member.fuzzy_search(%{query: substring})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
assert Enum.any?(results, fn m -> m.id == member1.id end),
|
assert Enum.any?(results, fn m -> m.id == member1.id end),
|
||||||
"Substring '#{substring}' should find member with custom field 'ActiveMember'"
|
"Substring '#{substring}' should find member with custom field 'ActiveMember'"
|
||||||
|
|
@ -633,6 +651,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "finds correct member among multiple with different custom field values", %{
|
test "finds correct member among multiple with different custom field values", %{
|
||||||
|
system_actor: system_actor,
|
||||||
member1: member1,
|
member1: member1,
|
||||||
member2: member2,
|
member2: member2,
|
||||||
member3: member3,
|
member3: member3,
|
||||||
|
|
@ -646,7 +665,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
custom_field_id: string_field.id,
|
custom_field_id: string_field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "Beginner"}
|
value: %{"_union_type" => "string", "_union_value" => "Beginner"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, _cfv2} =
|
{:ok, _cfv2} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|
|
@ -655,7 +674,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
custom_field_id: string_field.id,
|
custom_field_id: string_field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "Advanced"}
|
value: %{"_union_type" => "string", "_union_value" => "Advanced"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, _cfv3} =
|
{:ok, _cfv3} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|
|
@ -664,13 +683,13 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
custom_field_id: string_field.id,
|
custom_field_id: string_field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "Expert"}
|
value: %{"_union_type" => "string", "_union_value" => "Expert"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Search for "Begin" - should only find member1
|
# Search for "Begin" - should only find member1
|
||||||
results_begin =
|
results_begin =
|
||||||
Member
|
Member
|
||||||
|> Member.fuzzy_search(%{query: "Begin"})
|
|> Member.fuzzy_search(%{query: "Begin"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
assert length(results_begin) == 1
|
assert length(results_begin) == 1
|
||||||
assert List.first(results_begin).id == member1.id
|
assert List.first(results_begin).id == member1.id
|
||||||
|
|
@ -679,7 +698,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
results_advan =
|
results_advan =
|
||||||
Member
|
Member
|
||||||
|> Member.fuzzy_search(%{query: "Advan"})
|
|> Member.fuzzy_search(%{query: "Advan"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
assert length(results_advan) == 1
|
assert length(results_advan) == 1
|
||||||
assert List.first(results_advan).id == member2.id
|
assert List.first(results_advan).id == member2.id
|
||||||
|
|
@ -688,7 +707,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
results_exper =
|
results_exper =
|
||||||
Member
|
Member
|
||||||
|> Member.fuzzy_search(%{query: "Exper"})
|
|> Member.fuzzy_search(%{query: "Exper"})
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
assert length(results_exper) == 1
|
assert length(results_exper) == 1
|
||||||
assert List.first(results_exper).id == member3.id
|
assert List.first(results_exper).id == member3.id
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@ defmodule Mv.Membership.MemberTest do
|
||||||
use Mv.DataCase, async: false
|
use Mv.DataCase, async: false
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
|
||||||
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
%{actor: system_actor}
|
||||||
|
end
|
||||||
|
|
||||||
describe "Fields and Validations" do
|
describe "Fields and Validations" do
|
||||||
@valid_attrs %{
|
@valid_attrs %{
|
||||||
first_name: "John",
|
first_name: "John",
|
||||||
|
|
@ -16,60 +21,74 @@ defmodule Mv.Membership.MemberTest do
|
||||||
postal_code: "12345"
|
postal_code: "12345"
|
||||||
}
|
}
|
||||||
|
|
||||||
test "First name is optional" do
|
test "First name is optional", %{actor: actor} do
|
||||||
attrs = Map.delete(@valid_attrs, :first_name)
|
attrs = Map.delete(@valid_attrs, :first_name)
|
||||||
assert {:ok, _member} = Membership.create_member(attrs)
|
assert {:ok, _member} = Membership.create_member(attrs, actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "Last name is optional" do
|
test "Last name is optional", %{actor: actor} do
|
||||||
attrs = Map.delete(@valid_attrs, :last_name)
|
attrs = Map.delete(@valid_attrs, :last_name)
|
||||||
assert {:ok, _member} = Membership.create_member(attrs)
|
assert {:ok, _member} = Membership.create_member(attrs, actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "Email is required" do
|
test "Email is required", %{actor: actor} do
|
||||||
attrs = Map.put(@valid_attrs, :email, "")
|
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"
|
assert error_message(errors, :email) =~ "must be present"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "Email must be valid" do
|
test "Email must be valid", %{actor: actor} do
|
||||||
attrs = Map.put(@valid_attrs, :email, "test@")
|
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"
|
assert error_message(errors, :email) =~ "is not a valid email"
|
||||||
end
|
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))
|
attrs = Map.put(@valid_attrs, :join_date, Date.utc_today() |> Date.add(1))
|
||||||
|
|
||||||
assert {:error,
|
assert {:error,
|
||||||
%Ash.Error.Invalid{errors: [%Ash.Error.Changes.InvalidAttribute{field: :join_date}]}} =
|
%Ash.Error.Invalid{errors: [%Ash.Error.Changes.InvalidAttribute{field: :join_date}]}} =
|
||||||
Membership.create_member(attrs)
|
Membership.create_member(attrs, actor: actor)
|
||||||
end
|
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])
|
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"
|
assert error_message(errors, :exit_date) =~ "cannot be before join date"
|
||||||
attrs2 = Map.delete(@valid_attrs, :exit_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
|
end
|
||||||
|
|
||||||
test "Notes is optional" do
|
test "Notes is optional", %{actor: actor} do
|
||||||
attrs = Map.delete(@valid_attrs, :notes)
|
attrs = Map.delete(@valid_attrs, :notes)
|
||||||
assert {:ok, _member} = Membership.create_member(attrs)
|
assert {:ok, _member} = Membership.create_member(attrs, actor: actor)
|
||||||
end
|
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])
|
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
|
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")
|
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"
|
assert error_message(errors, :postal_code) =~ "must consist of 5 digits"
|
||||||
attrs2 = Map.delete(@valid_attrs, :postal_code)
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,13 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
%{actor: system_actor}
|
||||||
|
end
|
||||||
|
|
||||||
# Helper to create a membership fee type
|
# Helper to create a membership fee type
|
||||||
defp create_fee_type(attrs) do
|
defp create_fee_type(attrs, actor) do
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||||
amount: Decimal.new("50.00"),
|
amount: Decimal.new("50.00"),
|
||||||
|
|
@ -23,11 +28,11 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
||||||
|
|
||||||
MembershipFeeType
|
MembershipFeeType
|
||||||
|> Ash.Changeset.for_create(:create, attrs)
|
|> Ash.Changeset.for_create(:create, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a member
|
# Helper to create a member
|
||||||
defp create_member(attrs) do
|
defp create_member(attrs, actor) do
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
first_name: "Test",
|
first_name: "Test",
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
|
|
@ -39,11 +44,11 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
||||||
|
|
||||||
Member
|
Member
|
||||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a cycle
|
# Helper to create a cycle
|
||||||
defp create_cycle(member, fee_type, attrs) do
|
defp create_cycle(member, fee_type, attrs, actor) do
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
cycle_start: ~D[2024-01-01],
|
cycle_start: ~D[2024-01-01],
|
||||||
amount: Decimal.new("50.00"),
|
amount: Decimal.new("50.00"),
|
||||||
|
|
@ -56,17 +61,17 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
||||||
|
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|> Ash.Changeset.for_create(:create, attrs)
|
|> Ash.Changeset.for_create(:create, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "type change cycle regeneration" do
|
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()
|
today = Date.utc_today()
|
||||||
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.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")})
|
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")}, actor)
|
||||||
|
|
||||||
# Create member without fee type first to avoid auto-generation
|
# 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)
|
# Manually assign fee type (this will trigger cycle generation)
|
||||||
member =
|
member =
|
||||||
|
|
@ -74,7 +79,7 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|> Ash.Changeset.for_update(:update_member, %{
|
||||||
membership_fee_type_id: yearly_type1.id
|
membership_fee_type_id: yearly_type1.id
|
||||||
})
|
})
|
||||||
|> Ash.update!()
|
|> Ash.update!(actor: actor)
|
||||||
|
|
||||||
# Cycle generation runs synchronously in the same transaction
|
# Cycle generation runs synchronously in the same transaction
|
||||||
# No need to wait for async completion
|
# No need to wait for async completion
|
||||||
|
|
@ -89,39 +94,49 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
||||||
# Check if it already exists (from auto-generation), if not create it
|
# Check if it already exists (from auto-generation), if not create it
|
||||||
case MembershipFeeCycle
|
case MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start)
|
|> 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) ->
|
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
|
||||||
# Update to paid
|
# Update to paid
|
||||||
existing_cycle
|
existing_cycle
|
||||||
|> Ash.Changeset.for_update(:update, %{status: :paid})
|
|> Ash.Changeset.for_update(:update, %{status: :paid})
|
||||||
|> Ash.update!()
|
|> Ash.update!(actor: actor)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
create_cycle(member, yearly_type1, %{
|
create_cycle(
|
||||||
cycle_start: past_cycle_start,
|
member,
|
||||||
status: :paid,
|
yearly_type1,
|
||||||
amount: Decimal.new("100.00")
|
%{
|
||||||
})
|
cycle_start: past_cycle_start,
|
||||||
|
status: :paid,
|
||||||
|
amount: Decimal.new("100.00")
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Current cycle (unpaid) - should be regenerated
|
# Current cycle (unpaid) - should be regenerated
|
||||||
# Delete if exists (from auto-generation), then create with old amount
|
# Delete if exists (from auto-generation), then create with old amount
|
||||||
case MembershipFeeCycle
|
case MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|
|> 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) ->
|
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
|
||||||
Ash.destroy!(existing_cycle)
|
Ash.destroy!(existing_cycle, actor: actor)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
_current_cycle =
|
_current_cycle =
|
||||||
create_cycle(member, yearly_type1, %{
|
create_cycle(
|
||||||
cycle_start: current_cycle_start,
|
member,
|
||||||
status: :unpaid,
|
yearly_type1,
|
||||||
amount: Decimal.new("100.00")
|
%{
|
||||||
})
|
cycle_start: current_cycle_start,
|
||||||
|
status: :unpaid,
|
||||||
|
amount: Decimal.new("100.00")
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
# Change membership fee type (same interval, different amount)
|
# Change membership fee type (same interval, different amount)
|
||||||
assert {:ok, _updated_member} =
|
assert {:ok, _updated_member} =
|
||||||
|
|
@ -129,7 +144,7 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|> Ash.Changeset.for_update(:update_member, %{
|
||||||
membership_fee_type_id: yearly_type2.id
|
membership_fee_type_id: yearly_type2.id
|
||||||
})
|
})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
# Cycle regeneration runs synchronously in the same transaction
|
# Cycle regeneration runs synchronously in the same transaction
|
||||||
# No need to wait for async completion
|
# No need to wait for async completion
|
||||||
|
|
@ -138,7 +153,7 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
||||||
past_cycle_after =
|
past_cycle_after =
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start)
|
|> 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 past_cycle_after.status == :paid
|
||||||
assert Decimal.equal?(past_cycle_after.amount, Decimal.new("100.00"))
|
assert Decimal.equal?(past_cycle_after.amount, Decimal.new("100.00"))
|
||||||
|
|
@ -149,7 +164,7 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
||||||
new_current_cycle =
|
new_current_cycle =
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|
|> 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
|
# Verify it has the new type and amount
|
||||||
assert new_current_cycle.membership_fee_type_id == yearly_type2.id
|
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
|
member_id == ^member.id and cycle_start == ^current_cycle_start and
|
||||||
membership_fee_type_id == ^yearly_type1.id
|
membership_fee_type_id == ^yearly_type1.id
|
||||||
)
|
)
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
assert Enum.empty?(old_current_cycles)
|
assert Enum.empty?(old_current_cycles)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "paid cycles remain unchanged" do
|
test "paid cycles remain unchanged", %{actor: actor} do
|
||||||
today = Date.utc_today()
|
today = Date.utc_today()
|
||||||
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.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")})
|
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")}, actor)
|
||||||
|
|
||||||
# Create member without fee type first to avoid auto-generation
|
# 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)
|
# Manually assign fee type (this will trigger cycle generation)
|
||||||
member =
|
member =
|
||||||
|
|
@ -182,7 +197,7 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|> Ash.Changeset.for_update(:update_member, %{
|
||||||
membership_fee_type_id: yearly_type1.id
|
membership_fee_type_id: yearly_type1.id
|
||||||
})
|
})
|
||||||
|> Ash.update!()
|
|> Ash.update!(actor: actor)
|
||||||
|
|
||||||
# Cycle generation runs synchronously in the same transaction
|
# Cycle generation runs synchronously in the same transaction
|
||||||
# No need to wait for async completion
|
# No need to wait for async completion
|
||||||
|
|
@ -194,9 +209,9 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
||||||
paid_cycle =
|
paid_cycle =
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|
|> 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.Changeset.for_update(:mark_as_paid)
|
||||||
|> Ash.update!()
|
|> Ash.update!(actor: actor)
|
||||||
|
|
||||||
# Change membership fee type
|
# Change membership fee type
|
||||||
assert {:ok, _updated_member} =
|
assert {:ok, _updated_member} =
|
||||||
|
|
@ -204,25 +219,25 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|> Ash.Changeset.for_update(:update_member, %{
|
||||||
membership_fee_type_id: yearly_type2.id
|
membership_fee_type_id: yearly_type2.id
|
||||||
})
|
})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
# Cycle regeneration runs synchronously in the same transaction
|
# Cycle regeneration runs synchronously in the same transaction
|
||||||
# No need to wait for async completion
|
# No need to wait for async completion
|
||||||
|
|
||||||
# Verify paid cycle is unchanged (not deleted and regenerated)
|
# 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 cycle_after.status == :paid
|
||||||
assert Decimal.equal?(cycle_after.amount, Decimal.new("100.00"))
|
assert Decimal.equal?(cycle_after.amount, Decimal.new("100.00"))
|
||||||
assert cycle_after.membership_fee_type_id == yearly_type1.id
|
assert cycle_after.membership_fee_type_id == yearly_type1.id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "suspended cycles remain unchanged" do
|
test "suspended cycles remain unchanged", %{actor: actor} do
|
||||||
today = Date.utc_today()
|
today = Date.utc_today()
|
||||||
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.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")})
|
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")}, actor)
|
||||||
|
|
||||||
# Create member without fee type first to avoid auto-generation
|
# 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)
|
# Manually assign fee type (this will trigger cycle generation)
|
||||||
member =
|
member =
|
||||||
|
|
@ -230,7 +245,7 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|> Ash.Changeset.for_update(:update_member, %{
|
||||||
membership_fee_type_id: yearly_type1.id
|
membership_fee_type_id: yearly_type1.id
|
||||||
})
|
})
|
||||||
|> Ash.update!()
|
|> Ash.update!(actor: actor)
|
||||||
|
|
||||||
# Cycle generation runs synchronously in the same transaction
|
# Cycle generation runs synchronously in the same transaction
|
||||||
# No need to wait for async completion
|
# No need to wait for async completion
|
||||||
|
|
@ -242,9 +257,9 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
||||||
suspended_cycle =
|
suspended_cycle =
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|
|> 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.Changeset.for_update(:mark_as_suspended)
|
||||||
|> Ash.update!()
|
|> Ash.update!(actor: actor)
|
||||||
|
|
||||||
# Change membership fee type
|
# Change membership fee type
|
||||||
assert {:ok, _updated_member} =
|
assert {:ok, _updated_member} =
|
||||||
|
|
@ -252,25 +267,25 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|> Ash.Changeset.for_update(:update_member, %{
|
||||||
membership_fee_type_id: yearly_type2.id
|
membership_fee_type_id: yearly_type2.id
|
||||||
})
|
})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
# Cycle regeneration runs synchronously in the same transaction
|
# Cycle regeneration runs synchronously in the same transaction
|
||||||
# No need to wait for async completion
|
# No need to wait for async completion
|
||||||
|
|
||||||
# Verify suspended cycle is unchanged (not deleted and regenerated)
|
# 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 cycle_after.status == :suspended
|
||||||
assert Decimal.equal?(cycle_after.amount, Decimal.new("100.00"))
|
assert Decimal.equal?(cycle_after.amount, Decimal.new("100.00"))
|
||||||
assert cycle_after.membership_fee_type_id == yearly_type1.id
|
assert cycle_after.membership_fee_type_id == yearly_type1.id
|
||||||
end
|
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()
|
today = Date.utc_today()
|
||||||
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.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")})
|
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")}, actor)
|
||||||
|
|
||||||
# Create member without fee type first to avoid auto-generation
|
# 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)
|
# Manually assign fee type (this will trigger cycle generation)
|
||||||
member =
|
member =
|
||||||
|
|
@ -278,7 +293,7 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|> Ash.Changeset.for_update(:update_member, %{
|
||||||
membership_fee_type_id: yearly_type1.id
|
membership_fee_type_id: yearly_type1.id
|
||||||
})
|
})
|
||||||
|> Ash.update!()
|
|> Ash.update!(actor: actor)
|
||||||
|
|
||||||
# Cycle generation runs synchronously in the same transaction
|
# Cycle generation runs synchronously in the same transaction
|
||||||
# No need to wait for async completion
|
# No need to wait for async completion
|
||||||
|
|
@ -296,39 +311,49 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
||||||
# Delete existing cycle if it exists (from auto-generation)
|
# Delete existing cycle if it exists (from auto-generation)
|
||||||
case MembershipFeeCycle
|
case MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start)
|
|> 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) ->
|
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
|
||||||
Ash.destroy!(existing_cycle)
|
Ash.destroy!(existing_cycle, actor: actor)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
past_cycle =
|
past_cycle =
|
||||||
create_cycle(member, yearly_type1, %{
|
create_cycle(
|
||||||
cycle_start: past_cycle_start,
|
member,
|
||||||
status: :unpaid,
|
yearly_type1,
|
||||||
amount: Decimal.new("100.00")
|
%{
|
||||||
})
|
cycle_start: past_cycle_start,
|
||||||
|
status: :unpaid,
|
||||||
|
amount: Decimal.new("100.00")
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
# Current cycle (unpaid) - should be regenerated (cycle_start >= today)
|
# Current cycle (unpaid) - should be regenerated (cycle_start >= today)
|
||||||
# Delete existing cycle if it exists (from auto-generation)
|
# Delete existing cycle if it exists (from auto-generation)
|
||||||
case MembershipFeeCycle
|
case MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|
|> 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) ->
|
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
|
||||||
Ash.destroy!(existing_cycle)
|
Ash.destroy!(existing_cycle, actor: actor)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
_current_cycle =
|
_current_cycle =
|
||||||
create_cycle(member, yearly_type1, %{
|
create_cycle(
|
||||||
cycle_start: current_cycle_start,
|
member,
|
||||||
status: :unpaid,
|
yearly_type1,
|
||||||
amount: Decimal.new("100.00")
|
%{
|
||||||
})
|
cycle_start: current_cycle_start,
|
||||||
|
status: :unpaid,
|
||||||
|
amount: Decimal.new("100.00")
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
# Change membership fee type
|
# Change membership fee type
|
||||||
assert {:ok, _updated_member} =
|
assert {:ok, _updated_member} =
|
||||||
|
|
@ -336,13 +361,13 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|> Ash.Changeset.for_update(:update_member, %{
|
||||||
membership_fee_type_id: yearly_type2.id
|
membership_fee_type_id: yearly_type2.id
|
||||||
})
|
})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
# Cycle regeneration runs synchronously in the same transaction
|
# Cycle regeneration runs synchronously in the same transaction
|
||||||
# No need to wait for async completion
|
# No need to wait for async completion
|
||||||
|
|
||||||
# Verify past cycle is unchanged
|
# 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 past_cycle_after.status == :unpaid
|
||||||
assert Decimal.equal?(past_cycle_after.amount, Decimal.new("100.00"))
|
assert Decimal.equal?(past_cycle_after.amount, Decimal.new("100.00"))
|
||||||
assert past_cycle_after.membership_fee_type_id == yearly_type1.id
|
assert past_cycle_after.membership_fee_type_id == yearly_type1.id
|
||||||
|
|
@ -352,7 +377,7 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
||||||
new_current_cycle =
|
new_current_cycle =
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|
|> 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 new_current_cycle.membership_fee_type_id == yearly_type2.id
|
||||||
assert Decimal.equal?(new_current_cycle.amount, Decimal.new("150.00"))
|
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
|
member_id == ^member.id and cycle_start == ^current_cycle_start and
|
||||||
membership_fee_type_id == ^yearly_type1.id
|
membership_fee_type_id == ^yearly_type1.id
|
||||||
)
|
)
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
assert Enum.empty?(old_current_cycles)
|
assert Enum.empty?(old_current_cycles)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "member calculations update after type change" do
|
test "member calculations update after type change", %{actor: actor} do
|
||||||
today = Date.utc_today()
|
today = Date.utc_today()
|
||||||
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.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")})
|
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")}, actor)
|
||||||
|
|
||||||
# Create member with join_date = today to avoid past cycles
|
# Create member with join_date = today to avoid past cycles
|
||||||
# This ensures no overdue cycles exist
|
# 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)
|
# Manually assign fee type (this will trigger cycle generation)
|
||||||
member =
|
member =
|
||||||
|
|
@ -384,7 +409,7 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|> Ash.Changeset.for_update(:update_member, %{
|
||||||
membership_fee_type_id: yearly_type1.id
|
membership_fee_type_id: yearly_type1.id
|
||||||
})
|
})
|
||||||
|> Ash.update!()
|
|> Ash.update!(actor: actor)
|
||||||
|
|
||||||
# Cycle generation runs synchronously in the same transaction
|
# Cycle generation runs synchronously in the same transaction
|
||||||
# No need to wait for async completion
|
# No need to wait for async completion
|
||||||
|
|
@ -397,33 +422,38 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
||||||
existing_cycles =
|
existing_cycles =
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member.id)
|
|> Ash.Query.filter(member_id == ^member.id)
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
Enum.each(existing_cycles, fn cycle ->
|
Enum.each(existing_cycles, fn cycle ->
|
||||||
if cycle.cycle_start != current_cycle_start do
|
if cycle.cycle_start != current_cycle_start do
|
||||||
Ash.destroy!(cycle)
|
Ash.destroy!(cycle, actor: actor)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
# Ensure current cycle exists and is unpaid
|
# Ensure current cycle exists and is unpaid
|
||||||
case MembershipFeeCycle
|
case MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|
|> 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) ->
|
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
|
||||||
# Update to unpaid if it's not
|
# Update to unpaid if it's not
|
||||||
if existing_cycle.status != :unpaid do
|
if existing_cycle.status != :unpaid do
|
||||||
existing_cycle
|
existing_cycle
|
||||||
|> Ash.Changeset.for_update(:mark_as_unpaid)
|
|> Ash.Changeset.for_update(:mark_as_unpaid)
|
||||||
|> Ash.update!()
|
|> Ash.update!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
# Create if it doesn't exist
|
# Create if it doesn't exist
|
||||||
create_cycle(member, yearly_type1, %{
|
create_cycle(
|
||||||
cycle_start: current_cycle_start,
|
member,
|
||||||
status: :unpaid,
|
yearly_type1,
|
||||||
amount: Decimal.new("100.00")
|
%{
|
||||||
})
|
cycle_start: current_cycle_start,
|
||||||
|
status: :unpaid,
|
||||||
|
amount: Decimal.new("100.00")
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Load calculations before change
|
# Load calculations before change
|
||||||
|
|
@ -437,7 +467,7 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|> Ash.Changeset.for_update(:update_member, %{
|
||||||
membership_fee_type_id: yearly_type2.id
|
membership_fee_type_id: yearly_type2.id
|
||||||
})
|
})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
# Cycle regeneration runs synchronously in the same transaction
|
# Cycle regeneration runs synchronously in the same transaction
|
||||||
# No need to wait for async completion
|
# No need to wait for async completion
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,11 @@ defmodule Mv.Membership.MembershipFeeSettingsTest do
|
||||||
alias Mv.Membership.Setting
|
alias Mv.Membership.Setting
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
|
|
||||||
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
%{actor: system_actor}
|
||||||
|
end
|
||||||
|
|
||||||
describe "membership fee settings" do
|
describe "membership fee settings" do
|
||||||
test "default values are correct" do
|
test "default values are correct" do
|
||||||
{:ok, settings} = Mv.Membership.get_settings()
|
{:ok, settings} = Mv.Membership.get_settings()
|
||||||
|
|
@ -18,7 +23,7 @@ defmodule Mv.Membership.MembershipFeeSettingsTest do
|
||||||
assert %Setting{} = settings
|
assert %Setting{} = settings
|
||||||
end
|
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, settings} = Mv.Membership.get_settings()
|
||||||
|
|
||||||
{:ok, updated} =
|
{:ok, updated} =
|
||||||
|
|
@ -26,12 +31,12 @@ defmodule Mv.Membership.MembershipFeeSettingsTest do
|
||||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
||||||
include_joining_cycle: false
|
include_joining_cycle: false
|
||||||
})
|
})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
assert updated.include_joining_cycle == false
|
assert updated.include_joining_cycle == false
|
||||||
end
|
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, settings} = Mv.Membership.get_settings()
|
||||||
|
|
||||||
{:ok, updated} =
|
{:ok, updated} =
|
||||||
|
|
@ -39,12 +44,12 @@ defmodule Mv.Membership.MembershipFeeSettingsTest do
|
||||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
||||||
default_membership_fee_type_id: nil
|
default_membership_fee_type_id: nil
|
||||||
})
|
})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
assert updated.default_membership_fee_type_id == nil
|
assert updated.default_membership_fee_type_id == nil
|
||||||
end
|
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()
|
{:ok, settings} = Mv.Membership.get_settings()
|
||||||
|
|
||||||
# Create a valid fee type
|
# Create a valid fee type
|
||||||
|
|
@ -61,12 +66,12 @@ defmodule Mv.Membership.MembershipFeeSettingsTest do
|
||||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
||||||
default_membership_fee_type_id: fee_type.id
|
default_membership_fee_type_id: fee_type.id
|
||||||
})
|
})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
assert updated.default_membership_fee_type_id == fee_type.id
|
assert updated.default_membership_fee_type_id == fee_type.id
|
||||||
end
|
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()
|
{:ok, settings} = Mv.Membership.get_settings()
|
||||||
|
|
||||||
# Use a non-existent UUID
|
# Use a non-existent UUID
|
||||||
|
|
@ -77,7 +82,7 @@ defmodule Mv.Membership.MembershipFeeSettingsTest do
|
||||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
||||||
default_membership_fee_type_id: fake_uuid
|
default_membership_fee_type_id: fake_uuid
|
||||||
})
|
})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
assert error_on_field?(error, :default_membership_fee_type_id)
|
assert error_on_field?(error, :default_membership_fee_type_id)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,18 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
|
||||||
|
|
||||||
alias Mv.MembershipFees.Changes.SetMembershipFeeStartDate
|
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
|
# 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()
|
{:ok, settings} = Mv.Membership.get_settings()
|
||||||
|
|
||||||
settings
|
settings
|
||||||
|> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle})
|
|> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle})
|
||||||
|> Ash.update!()
|
|> Ash.update!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "calculate_start_date/3" do
|
describe "calculate_start_date/3" do
|
||||||
|
|
@ -127,8 +132,8 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "change/3 integration" do
|
describe "change/3 integration" do
|
||||||
test "sets membership_fee_start_date automatically on member creation" do
|
test "sets membership_fee_start_date automatically on member creation", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
|
|
||||||
# Create a fee type
|
# Create a fee type
|
||||||
fee_type =
|
fee_type =
|
||||||
|
|
@ -138,7 +143,7 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
|
||||||
amount: Decimal.new("50.00"),
|
amount: Decimal.new("50.00"),
|
||||||
interval: :yearly
|
interval: :yearly
|
||||||
})
|
})
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
|
|
||||||
# Create member with join_date and fee type but no explicit start date
|
# Create member with join_date and fee type but no explicit start date
|
||||||
member =
|
member =
|
||||||
|
|
@ -150,14 +155,14 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
|
||||||
join_date: ~D[2024-03-15],
|
join_date: ~D[2024-03-15],
|
||||||
membership_fee_type_id: fee_type.id
|
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)
|
# 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]
|
assert member.membership_fee_start_date == ~D[2024-01-01]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does not override manually set membership_fee_start_date" do
|
test "does not override manually set membership_fee_start_date", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
|
|
||||||
# Create a fee type
|
# Create a fee type
|
||||||
fee_type =
|
fee_type =
|
||||||
|
|
@ -167,7 +172,7 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
|
||||||
amount: Decimal.new("50.00"),
|
amount: Decimal.new("50.00"),
|
||||||
interval: :yearly
|
interval: :yearly
|
||||||
})
|
})
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
|
|
||||||
# Create member with explicit start date
|
# Create member with explicit start date
|
||||||
manual_start_date = ~D[2024-07-01]
|
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_type_id: fee_type.id,
|
||||||
membership_fee_start_date: manual_start_date
|
membership_fee_start_date: manual_start_date
|
||||||
})
|
})
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
|
|
||||||
# Should keep the manually set date
|
# Should keep the manually set date
|
||||||
assert member.membership_fee_start_date == manual_start_date
|
assert member.membership_fee_start_date == manual_start_date
|
||||||
end
|
end
|
||||||
|
|
||||||
test "respects include_joining_cycle = false setting" do
|
test "respects include_joining_cycle = false setting", %{actor: actor} do
|
||||||
setup_settings(false)
|
setup_settings(false, actor)
|
||||||
|
|
||||||
# Create a fee type
|
# Create a fee type
|
||||||
fee_type =
|
fee_type =
|
||||||
|
|
@ -199,7 +204,7 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
|
||||||
amount: Decimal.new("50.00"),
|
amount: Decimal.new("50.00"),
|
||||||
interval: :yearly
|
interval: :yearly
|
||||||
})
|
})
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
|
|
||||||
# Create member
|
# Create member
|
||||||
member =
|
member =
|
||||||
|
|
@ -211,14 +216,14 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
|
||||||
join_date: ~D[2024-03-15],
|
join_date: ~D[2024-03-15],
|
||||||
membership_fee_type_id: fee_type.id
|
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)
|
# 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]
|
assert member.membership_fee_start_date == ~D[2025-01-01]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does not set start date without join_date" do
|
test "does not set start date without join_date", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
|
|
||||||
# Create a fee type
|
# Create a fee type
|
||||||
fee_type =
|
fee_type =
|
||||||
|
|
@ -228,7 +233,7 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
|
||||||
amount: Decimal.new("50.00"),
|
amount: Decimal.new("50.00"),
|
||||||
interval: :yearly
|
interval: :yearly
|
||||||
})
|
})
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
|
|
||||||
# Create member without join_date
|
# Create member without join_date
|
||||||
member =
|
member =
|
||||||
|
|
@ -240,14 +245,14 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
|
||||||
membership_fee_type_id: fee_type.id
|
membership_fee_type_id: fee_type.id
|
||||||
# No join_date
|
# No join_date
|
||||||
})
|
})
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
|
|
||||||
# Should not have auto-calculated start date
|
# Should not have auto-calculated start date
|
||||||
assert is_nil(member.membership_fee_start_date)
|
assert is_nil(member.membership_fee_start_date)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does not set start date without membership_fee_type_id" do
|
test "does not set start date without membership_fee_type_id", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
|
|
||||||
# Create member without fee type
|
# Create member without fee type
|
||||||
member =
|
member =
|
||||||
|
|
@ -259,7 +264,7 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
|
||||||
join_date: ~D[2024-03-15]
|
join_date: ~D[2024-03-15]
|
||||||
# No membership_fee_type_id
|
# No membership_fee_type_id
|
||||||
})
|
})
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
|
|
||||||
# Should not have auto-calculated start date
|
# Should not have auto-calculated start date
|
||||||
assert is_nil(member.membership_fee_start_date)
|
assert is_nil(member.membership_fee_start_date)
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,13 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
alias Mv.MembershipFees.Changes.ValidateSameInterval
|
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
|
# Helper to create a membership fee type
|
||||||
defp create_fee_type(attrs) do
|
defp create_fee_type(attrs, actor) do
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||||
amount: Decimal.new("50.00"),
|
amount: Decimal.new("50.00"),
|
||||||
|
|
@ -20,11 +25,11 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
||||||
|
|
||||||
MembershipFeeType
|
MembershipFeeType
|
||||||
|> Ash.Changeset.for_create(:create, attrs)
|
|> Ash.Changeset.for_create(:create, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a member
|
# Helper to create a member
|
||||||
defp create_member(attrs) do
|
defp create_member(attrs, actor) do
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
first_name: "Test",
|
first_name: "Test",
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
|
|
@ -35,15 +40,15 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
||||||
|
|
||||||
Member
|
Member
|
||||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "validate_interval_match/1" do
|
describe "validate_interval_match/1" do
|
||||||
test "allows change to type with same interval" do
|
test "allows change to type with same interval", %{actor: actor} do
|
||||||
yearly_type1 = create_fee_type(%{interval: :yearly, name: "Yearly Type 1"})
|
yearly_type1 = create_fee_type(%{interval: :yearly, name: "Yearly Type 1"}, actor)
|
||||||
yearly_type2 = create_fee_type(%{interval: :yearly, name: "Yearly Type 2"})
|
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 =
|
changeset =
|
||||||
member
|
member
|
||||||
|
|
@ -55,11 +60,11 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
||||||
assert changeset.valid?
|
assert changeset.valid?
|
||||||
end
|
end
|
||||||
|
|
||||||
test "prevents change to type with different interval" do
|
test "prevents change to type with different interval", %{actor: actor} do
|
||||||
yearly_type = create_fee_type(%{interval: :yearly})
|
yearly_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
monthly_type = create_fee_type(%{interval: :monthly})
|
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 =
|
changeset =
|
||||||
member
|
member
|
||||||
|
|
@ -78,10 +83,10 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "allows first assignment of membership fee type" do
|
test "allows first assignment of membership fee type", %{actor: actor} do
|
||||||
yearly_type = create_fee_type(%{interval: :yearly})
|
yearly_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
# No fee type assigned
|
# No fee type assigned
|
||||||
member = create_member(%{})
|
member = create_member(%{}, actor)
|
||||||
|
|
||||||
changeset =
|
changeset =
|
||||||
member
|
member
|
||||||
|
|
@ -93,9 +98,9 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
||||||
assert changeset.valid?
|
assert changeset.valid?
|
||||||
end
|
end
|
||||||
|
|
||||||
test "prevents removal of membership fee type" do
|
test "prevents removal of membership fee type", %{actor: actor} do
|
||||||
yearly_type = create_fee_type(%{interval: :yearly})
|
yearly_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
member = create_member(%{membership_fee_type_id: yearly_type.id})
|
member = create_member(%{membership_fee_type_id: yearly_type.id}, actor)
|
||||||
|
|
||||||
changeset =
|
changeset =
|
||||||
member
|
member
|
||||||
|
|
@ -113,9 +118,9 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does nothing when membership_fee_type_id is not changed" do
|
test "does nothing when membership_fee_type_id is not changed", %{actor: actor} do
|
||||||
yearly_type = create_fee_type(%{interval: :yearly})
|
yearly_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
member = create_member(%{membership_fee_type_id: yearly_type.id})
|
member = create_member(%{membership_fee_type_id: yearly_type.id}, actor)
|
||||||
|
|
||||||
changeset =
|
changeset =
|
||||||
member
|
member
|
||||||
|
|
@ -127,11 +132,11 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
||||||
assert changeset.valid?
|
assert changeset.valid?
|
||||||
end
|
end
|
||||||
|
|
||||||
test "error message is clear and helpful" do
|
test "error message is clear and helpful", %{actor: actor} do
|
||||||
yearly_type = create_fee_type(%{interval: :yearly})
|
yearly_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
quarterly_type = create_fee_type(%{interval: :quarterly})
|
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 =
|
changeset =
|
||||||
member
|
member
|
||||||
|
|
@ -146,25 +151,31 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
||||||
assert error.message =~ "same-interval"
|
assert error.message =~ "same-interval"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles all interval types correctly" do
|
test "handles all interval types correctly", %{actor: actor} do
|
||||||
intervals = [:monthly, :quarterly, :half_yearly, :yearly]
|
intervals = [:monthly, :quarterly, :half_yearly, :yearly]
|
||||||
|
|
||||||
for interval1 <- intervals,
|
for interval1 <- intervals,
|
||||||
interval2 <- intervals,
|
interval2 <- intervals,
|
||||||
interval1 != interval2 do
|
interval1 != interval2 do
|
||||||
type1 =
|
type1 =
|
||||||
create_fee_type(%{
|
create_fee_type(
|
||||||
interval: interval1,
|
%{
|
||||||
name: "Type #{interval1} #{System.unique_integer([:positive])}"
|
interval: interval1,
|
||||||
})
|
name: "Type #{interval1} #{System.unique_integer([:positive])}"
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
type2 =
|
type2 =
|
||||||
create_fee_type(%{
|
create_fee_type(
|
||||||
interval: interval2,
|
%{
|
||||||
name: "Type #{interval2} #{System.unique_integer([:positive])}"
|
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 =
|
changeset =
|
||||||
member
|
member
|
||||||
|
|
@ -180,11 +191,11 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "integration with update_member action" do
|
describe "integration with update_member action" do
|
||||||
test "validation works when updating member via update_member action" do
|
test "validation works when updating member via update_member action", %{actor: actor} do
|
||||||
yearly_type = create_fee_type(%{interval: :yearly})
|
yearly_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
monthly_type = create_fee_type(%{interval: :monthly})
|
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
|
# Try to update member with different interval type
|
||||||
assert {:error, %Ash.Error.Invalid{} = error} =
|
assert {:error, %Ash.Error.Invalid{} = error} =
|
||||||
|
|
@ -192,7 +203,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|> Ash.Changeset.for_update(:update_member, %{
|
||||||
membership_fee_type_id: monthly_type.id
|
membership_fee_type_id: monthly_type.id
|
||||||
})
|
})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
# Check that error is about interval mismatch
|
# Check that error is about interval mismatch
|
||||||
error_message = extract_error_message(error)
|
error_message = extract_error_message(error)
|
||||||
|
|
@ -201,11 +212,11 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
||||||
assert error_message =~ "same-interval"
|
assert error_message =~ "same-interval"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "allows update when interval matches" do
|
test "allows update when interval matches", %{actor: actor} do
|
||||||
yearly_type1 = create_fee_type(%{interval: :yearly, name: "Yearly Type 1"})
|
yearly_type1 = create_fee_type(%{interval: :yearly, name: "Yearly Type 1"}, actor)
|
||||||
yearly_type2 = create_fee_type(%{interval: :yearly, name: "Yearly Type 2"})
|
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
|
# Update member with same-interval type
|
||||||
assert {:ok, updated_member} =
|
assert {:ok, updated_member} =
|
||||||
|
|
@ -213,7 +224,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|> Ash.Changeset.for_update(:update_member, %{
|
||||||
membership_fee_type_id: yearly_type2.id
|
membership_fee_type_id: yearly_type2.id
|
||||||
})
|
})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
assert updated_member.membership_fee_type_id == yearly_type2.id
|
assert updated_member.membership_fee_type_id == yearly_type2.id
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -8,211 +8,287 @@ defmodule Mv.MembershipFees.ForeignKeyTest do
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
alias Mv.Membership.Member
|
alias Mv.Membership.Member
|
||||||
|
|
||||||
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
%{actor: system_actor}
|
||||||
|
end
|
||||||
|
|
||||||
describe "CASCADE behavior" do
|
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
|
# Create member
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Ash.create(Member, %{
|
Ash.create(
|
||||||
first_name: "Cascade",
|
Member,
|
||||||
last_name: "Test",
|
%{
|
||||||
email: "cascade.test.#{System.unique_integer([:positive])}@example.com"
|
first_name: "Cascade",
|
||||||
})
|
last_name: "Test",
|
||||||
|
email: "cascade.test.#{System.unique_integer([:positive])}@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Create fee type
|
# Create fee type
|
||||||
{:ok, fee_type} =
|
{:ok, fee_type} =
|
||||||
Ash.create(MembershipFeeType, %{
|
Ash.create(
|
||||||
name: "Cascade Test Fee #{System.unique_integer([:positive])}",
|
MembershipFeeType,
|
||||||
amount: Decimal.new("100.00"),
|
%{
|
||||||
interval: :monthly
|
name: "Cascade Test Fee #{System.unique_integer([:positive])}",
|
||||||
})
|
amount: Decimal.new("100.00"),
|
||||||
|
interval: :monthly
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Create multiple cycles for this member
|
# Create multiple cycles for this member
|
||||||
{:ok, cycle1} =
|
{:ok, cycle1} =
|
||||||
Ash.create(MembershipFeeCycle, %{
|
Ash.create(
|
||||||
cycle_start: ~D[2025-01-01],
|
MembershipFeeCycle,
|
||||||
amount: Decimal.new("100.00"),
|
%{
|
||||||
member_id: member.id,
|
cycle_start: ~D[2025-01-01],
|
||||||
membership_fee_type_id: fee_type.id
|
amount: Decimal.new("100.00"),
|
||||||
})
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, cycle2} =
|
{:ok, cycle2} =
|
||||||
Ash.create(MembershipFeeCycle, %{
|
Ash.create(
|
||||||
cycle_start: ~D[2025-02-01],
|
MembershipFeeCycle,
|
||||||
amount: Decimal.new("100.00"),
|
%{
|
||||||
member_id: member.id,
|
cycle_start: ~D[2025-02-01],
|
||||||
membership_fee_type_id: fee_type.id
|
amount: Decimal.new("100.00"),
|
||||||
})
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Verify cycles exist
|
# Verify cycles exist
|
||||||
assert {:ok, _} = Ash.get(MembershipFeeCycle, cycle1.id)
|
assert {:ok, _} = Ash.get(MembershipFeeCycle, cycle1.id, actor: actor)
|
||||||
assert {:ok, _} = Ash.get(MembershipFeeCycle, cycle2.id)
|
assert {:ok, _} = Ash.get(MembershipFeeCycle, cycle2.id, actor: actor)
|
||||||
|
|
||||||
# Delete member
|
# Delete member
|
||||||
assert :ok = Ash.destroy(member)
|
assert :ok = Ash.destroy(member, actor: actor)
|
||||||
|
|
||||||
# Verify cycles are also deleted (CASCADE)
|
# Verify cycles are also deleted (CASCADE)
|
||||||
# NotFound is wrapped in Ash.Error.Invalid
|
# 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, cycle1.id, actor: actor)
|
||||||
assert {:error, %Ash.Error.Invalid{}} = Ash.get(MembershipFeeCycle, cycle2.id)
|
assert {:error, %Ash.Error.Invalid{}} = Ash.get(MembershipFeeCycle, cycle2.id, actor: actor)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "RESTRICT behavior" do
|
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
|
# Create member
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Ash.create(Member, %{
|
Ash.create(
|
||||||
first_name: "Restrict",
|
Member,
|
||||||
last_name: "Test",
|
%{
|
||||||
email: "restrict.test.#{System.unique_integer([:positive])}@example.com"
|
first_name: "Restrict",
|
||||||
})
|
last_name: "Test",
|
||||||
|
email: "restrict.test.#{System.unique_integer([:positive])}@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Create fee type
|
# Create fee type
|
||||||
{:ok, fee_type} =
|
{:ok, fee_type} =
|
||||||
Ash.create(MembershipFeeType, %{
|
Ash.create(
|
||||||
name: "Restrict Test Fee #{System.unique_integer([:positive])}",
|
MembershipFeeType,
|
||||||
amount: Decimal.new("100.00"),
|
%{
|
||||||
interval: :monthly
|
name: "Restrict Test Fee #{System.unique_integer([:positive])}",
|
||||||
})
|
amount: Decimal.new("100.00"),
|
||||||
|
interval: :monthly
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Create a cycle referencing this fee type
|
# Create a cycle referencing this fee type
|
||||||
{:ok, _cycle} =
|
{:ok, _cycle} =
|
||||||
Ash.create(MembershipFeeCycle, %{
|
Ash.create(
|
||||||
cycle_start: ~D[2025-01-01],
|
MembershipFeeCycle,
|
||||||
amount: Decimal.new("100.00"),
|
%{
|
||||||
member_id: member.id,
|
cycle_start: ~D[2025-01-01],
|
||||||
membership_fee_type_id: fee_type.id
|
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
|
# 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
|
# Check that it's a foreign key violation error
|
||||||
assert is_struct(error, Ash.Error.Invalid) or is_struct(error, Ash.Error.Unknown)
|
assert is_struct(error, Ash.Error.Invalid) or is_struct(error, Ash.Error.Unknown)
|
||||||
end
|
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
|
# Create fee type without any cycles
|
||||||
{:ok, fee_type} =
|
{:ok, fee_type} =
|
||||||
Ash.create(MembershipFeeType, %{
|
Ash.create(
|
||||||
name: "Deletable Fee #{System.unique_integer([:positive])}",
|
MembershipFeeType,
|
||||||
amount: Decimal.new("100.00"),
|
%{
|
||||||
interval: :monthly
|
name: "Deletable Fee #{System.unique_integer([:positive])}",
|
||||||
})
|
amount: Decimal.new("100.00"),
|
||||||
|
interval: :monthly
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Should be able to delete
|
# 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)
|
# 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
|
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
|
# Create fee type
|
||||||
{:ok, fee_type} =
|
{:ok, fee_type} =
|
||||||
Ash.create(MembershipFeeType, %{
|
Ash.create(
|
||||||
name: "Member Ref Fee #{System.unique_integer([:positive])}",
|
MembershipFeeType,
|
||||||
amount: Decimal.new("100.00"),
|
%{
|
||||||
interval: :monthly
|
name: "Member Ref Fee #{System.unique_integer([:positive])}",
|
||||||
})
|
amount: Decimal.new("100.00"),
|
||||||
|
interval: :monthly
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Create member with this fee type
|
# Create member with this fee type
|
||||||
{:ok, _member} =
|
{:ok, _member} =
|
||||||
Ash.create(Member, %{
|
Ash.create(
|
||||||
first_name: "FeeType",
|
Member,
|
||||||
last_name: "Reference",
|
%{
|
||||||
email: "feetype.ref.#{System.unique_integer([:positive])}@example.com",
|
first_name: "FeeType",
|
||||||
membership_fee_type_id: fee_type.id
|
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
|
# 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)
|
assert is_struct(error, Ash.Error.Invalid) or is_struct(error, Ash.Error.Unknown)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "member extensions" do
|
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
|
# Create fee type first
|
||||||
{:ok, fee_type} =
|
{:ok, fee_type} =
|
||||||
Ash.create(MembershipFeeType, %{
|
Ash.create(
|
||||||
name: "Create Test Fee #{System.unique_integer([:positive])}",
|
MembershipFeeType,
|
||||||
amount: Decimal.new("100.00"),
|
%{
|
||||||
interval: :yearly
|
name: "Create Test Fee #{System.unique_integer([:positive])}",
|
||||||
})
|
amount: Decimal.new("100.00"),
|
||||||
|
interval: :yearly
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Create member with fee type
|
# Create member with fee type
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Ash.create(Member, %{
|
Ash.create(
|
||||||
first_name: "With",
|
Member,
|
||||||
last_name: "FeeType",
|
%{
|
||||||
email: "with.feetype.#{System.unique_integer([:positive])}@example.com",
|
first_name: "With",
|
||||||
membership_fee_type_id: fee_type.id
|
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
|
assert member.membership_fee_type_id == fee_type.id
|
||||||
end
|
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} =
|
{:ok, member} =
|
||||||
Ash.create(Member, %{
|
Ash.create(
|
||||||
first_name: "With",
|
Member,
|
||||||
last_name: "StartDate",
|
%{
|
||||||
email: "with.startdate.#{System.unique_integer([:positive])}@example.com",
|
first_name: "With",
|
||||||
membership_fee_start_date: ~D[2025-01-01]
|
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]
|
assert member.membership_fee_start_date == ~D[2025-01-01]
|
||||||
end
|
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} =
|
{:ok, member} =
|
||||||
Ash.create(Member, %{
|
Ash.create(
|
||||||
first_name: "No",
|
Member,
|
||||||
last_name: "FeeFields",
|
%{
|
||||||
email: "no.feefields.#{System.unique_integer([:positive])}@example.com"
|
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_type_id == nil
|
||||||
assert member.membership_fee_start_date == nil
|
assert member.membership_fee_start_date == nil
|
||||||
end
|
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
|
# Create fee type
|
||||||
{:ok, fee_type} =
|
{:ok, fee_type} =
|
||||||
Ash.create(MembershipFeeType, %{
|
Ash.create(
|
||||||
name: "Update Test Fee #{System.unique_integer([:positive])}",
|
MembershipFeeType,
|
||||||
amount: Decimal.new("100.00"),
|
%{
|
||||||
interval: :yearly
|
name: "Update Test Fee #{System.unique_integer([:positive])}",
|
||||||
})
|
amount: Decimal.new("100.00"),
|
||||||
|
interval: :yearly
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Create member without fee type
|
# Create member without fee type
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Ash.create(Member, %{
|
Ash.create(
|
||||||
first_name: "Update",
|
Member,
|
||||||
last_name: "Test",
|
%{
|
||||||
email: "update.test.#{System.unique_integer([:positive])}@example.com"
|
first_name: "Update",
|
||||||
})
|
last_name: "Test",
|
||||||
|
email: "update.test.#{System.unique_integer([:positive])}@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
assert member.membership_fee_type_id == nil
|
assert member.membership_fee_type_id == nil
|
||||||
|
|
||||||
# Update member with fee type
|
# 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
|
assert updated_member.membership_fee_type_id == fee_type.id
|
||||||
end
|
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} =
|
{:ok, member} =
|
||||||
Ash.create(Member, %{
|
Ash.create(
|
||||||
first_name: "Start",
|
Member,
|
||||||
last_name: "Date",
|
%{
|
||||||
email: "start.date.#{System.unique_integer([:positive])}@example.com"
|
first_name: "Start",
|
||||||
})
|
last_name: "Date",
|
||||||
|
email: "start.date.#{System.unique_integer([:positive])}@example.com"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
assert member.membership_fee_start_date == nil
|
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]
|
assert updated_member.membership_fee_start_date == ~D[2025-06-01]
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,13 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
%{actor: system_actor}
|
||||||
|
end
|
||||||
|
|
||||||
# Helper to create a membership fee type
|
# Helper to create a membership fee type
|
||||||
defp create_fee_type(attrs) do
|
defp create_fee_type(attrs, actor) do
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||||
amount: Decimal.new("50.00"),
|
amount: Decimal.new("50.00"),
|
||||||
|
|
@ -22,30 +27,30 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
|
||||||
|
|
||||||
MembershipFeeType
|
MembershipFeeType
|
||||||
|> Ash.Changeset.for_create(:create, attrs)
|
|> Ash.Changeset.for_create(:create, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to set up settings
|
# 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()
|
{:ok, settings} = Mv.Membership.get_settings()
|
||||||
|
|
||||||
settings
|
settings
|
||||||
|> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle})
|
|> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle})
|
||||||
|> Ash.update!()
|
|> Ash.update!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to get cycles for a member
|
# Helper to get cycles for a member
|
||||||
defp get_member_cycles(member_id) do
|
defp get_member_cycles(member_id, actor) do
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member_id)
|
|> Ash.Query.filter(member_id == ^member_id)
|
||||||
|> Ash.Query.sort(cycle_start: :asc)
|
|> Ash.Query.sort(cycle_start: :asc)
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "member creation triggers cycle generation" do
|
describe "member creation triggers cycle generation" do
|
||||||
test "creates cycles when member is created with fee type and join_date" do
|
test "creates cycles when member is created with fee type and join_date", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
|
||||||
member =
|
member =
|
||||||
Member
|
Member
|
||||||
|
|
@ -56,9 +61,9 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
|
||||||
join_date: ~D[2023-03-15],
|
join_date: ~D[2023-03-15],
|
||||||
membership_fee_type_id: fee_type.id
|
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)
|
# Should have cycles for 2023 and 2024 (and possibly current year)
|
||||||
assert length(cycles) >= 2
|
assert length(cycles) >= 2
|
||||||
|
|
@ -72,8 +77,8 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does not create cycles when member has no fee type" do
|
test "does not create cycles when member has no fee type", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
|
|
||||||
member =
|
member =
|
||||||
Member
|
Member
|
||||||
|
|
@ -84,16 +89,16 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
|
||||||
join_date: ~D[2023-03-15]
|
join_date: ~D[2023-03-15]
|
||||||
# No membership_fee_type_id
|
# 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 == []
|
assert cycles == []
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does not create cycles when member has no join_date" do
|
test "does not create cycles when member has no join_date", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
|
||||||
member =
|
member =
|
||||||
Member
|
Member
|
||||||
|
|
@ -104,18 +109,18 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
|
||||||
membership_fee_type_id: fee_type.id
|
membership_fee_type_id: fee_type.id
|
||||||
# No join_date
|
# No join_date
|
||||||
})
|
})
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
|
|
||||||
cycles = get_member_cycles(member.id)
|
cycles = get_member_cycles(member.id, actor)
|
||||||
|
|
||||||
assert cycles == []
|
assert cycles == []
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "member update triggers cycle generation" do
|
describe "member update triggers cycle generation" do
|
||||||
test "generates cycles when fee type is assigned to existing member" do
|
test "generates cycles when fee type is assigned to existing member", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
|
||||||
# Create member without fee type
|
# Create member without fee type
|
||||||
member =
|
member =
|
||||||
|
|
@ -126,17 +131,17 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
|
||||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||||
join_date: ~D[2023-03-15]
|
join_date: ~D[2023-03-15]
|
||||||
})
|
})
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
|
|
||||||
# Verify no cycles yet
|
# Verify no cycles yet
|
||||||
assert get_member_cycles(member.id) == []
|
assert get_member_cycles(member.id, actor) == []
|
||||||
|
|
||||||
# Update to assign fee type
|
# Update to assign fee type
|
||||||
member
|
member
|
||||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
|> 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
|
# Should have generated cycles
|
||||||
assert length(cycles) >= 2
|
assert length(cycles) >= 2
|
||||||
|
|
@ -144,9 +149,9 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "concurrent cycle generation" do
|
describe "concurrent cycle generation" do
|
||||||
test "handles multiple members being created concurrently" do
|
test "handles multiple members being created concurrently", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
|
||||||
# Create multiple members concurrently
|
# Create multiple members concurrently
|
||||||
tasks =
|
tasks =
|
||||||
|
|
@ -160,7 +165,7 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
|
||||||
join_date: ~D[2023-03-15],
|
join_date: ~D[2023-03-15],
|
||||||
membership_fee_type_id: fee_type.id
|
membership_fee_type_id: fee_type.id
|
||||||
})
|
})
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -168,16 +173,16 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
|
||||||
|
|
||||||
# Each member should have cycles
|
# Each member should have cycles
|
||||||
Enum.each(members, fn member ->
|
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"
|
assert length(cycles) >= 2, "Member #{member.id} should have at least 2 cycles"
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "idempotent cycle generation" do
|
describe "idempotent cycle generation" do
|
||||||
test "running generation multiple times does not create duplicate cycles" do
|
test "running generation multiple times does not create duplicate cycles", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
|
||||||
member =
|
member =
|
||||||
Member
|
Member
|
||||||
|
|
@ -188,9 +193,9 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
|
||||||
join_date: ~D[2023-03-15],
|
join_date: ~D[2023-03-15],
|
||||||
membership_fee_type_id: fee_type.id
|
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)
|
initial_count = length(initial_cycles)
|
||||||
|
|
||||||
# Use a fixed "today" date to avoid date dependency
|
# Use a fixed "today" date to avoid date dependency
|
||||||
|
|
@ -201,7 +206,7 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
|
||||||
{:ok, _, _} =
|
{:ok, _, _} =
|
||||||
Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
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)
|
final_count = length(final_cycles)
|
||||||
|
|
||||||
# Should have same number of cycles (idempotent)
|
# Should have same number of cycles (idempotent)
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,13 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
alias Mv.Membership.Member
|
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
|
# Helper to create a membership fee type
|
||||||
defp create_fee_type(attrs) do
|
defp create_fee_type(attrs, actor) do
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||||
amount: Decimal.new("50.00"),
|
amount: Decimal.new("50.00"),
|
||||||
|
|
@ -20,11 +25,11 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
|
||||||
|
|
||||||
MembershipFeeType
|
MembershipFeeType
|
||||||
|> Ash.Changeset.for_create(:create, attrs)
|
|> Ash.Changeset.for_create(:create, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a member
|
# Helper to create a member
|
||||||
defp create_member(attrs) do
|
defp create_member(attrs, actor) do
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
first_name: "Test",
|
first_name: "Test",
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
|
|
@ -35,11 +40,11 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
|
||||||
|
|
||||||
Member
|
Member
|
||||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a cycle
|
# Helper to create a cycle
|
||||||
defp create_cycle(member, fee_type, attrs) do
|
defp create_cycle(member, fee_type, attrs, actor) do
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
cycle_start: ~D[2024-01-01],
|
cycle_start: ~D[2024-01-01],
|
||||||
amount: Decimal.new("50.00"),
|
amount: Decimal.new("50.00"),
|
||||||
|
|
@ -51,13 +56,13 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
|
||||||
|
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|> Ash.Changeset.for_create(:create, attrs)
|
|> Ash.Changeset.for_create(:create, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "status defaults" do
|
describe "status defaults" do
|
||||||
test "status defaults to :unpaid when creating a cycle" do
|
test "status defaults to :unpaid when creating a cycle", %{actor: actor} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||||
|
|
||||||
cycle =
|
cycle =
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|
|
@ -67,29 +72,30 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
|
||||||
member_id: member.id,
|
member_id: member.id,
|
||||||
membership_fee_type_id: fee_type.id
|
membership_fee_type_id: fee_type.id
|
||||||
})
|
})
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
|
|
||||||
assert cycle.status == :unpaid
|
assert cycle.status == :unpaid
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "mark_as_paid" do
|
describe "mark_as_paid" do
|
||||||
test "sets status to :paid" do
|
test "sets status to :paid", %{actor: actor} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||||
cycle = create_cycle(member, fee_type, %{status: :unpaid})
|
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
|
assert updated.status == :paid
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can set notes when marking as paid" do
|
test "can set notes when marking as paid", %{actor: actor} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||||
cycle = create_cycle(member, fee_type, %{status: :unpaid})
|
cycle = create_cycle(member, fee_type, %{status: :unpaid}, actor)
|
||||||
|
|
||||||
assert {:ok, updated} =
|
assert {:ok, updated} =
|
||||||
Ash.update(cycle, %{notes: "Payment received via bank transfer"},
|
Ash.update(cycle, %{notes: "Payment received via bank transfer"},
|
||||||
|
actor: actor,
|
||||||
action: :mark_as_paid
|
action: :mark_as_paid
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -97,33 +103,34 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
|
||||||
assert updated.notes == "Payment received via bank transfer"
|
assert updated.notes == "Payment received via bank transfer"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can change from suspended to paid" do
|
test "can change from suspended to paid", %{actor: actor} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||||
cycle = create_cycle(member, fee_type, %{status: :suspended})
|
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
|
assert updated.status == :paid
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "mark_as_suspended" do
|
describe "mark_as_suspended" do
|
||||||
test "sets status to :suspended" do
|
test "sets status to :suspended", %{actor: actor} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||||
cycle = create_cycle(member, fee_type, %{status: :unpaid})
|
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
|
assert updated.status == :suspended
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can set notes when marking as suspended" do
|
test "can set notes when marking as suspended", %{actor: actor} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||||
cycle = create_cycle(member, fee_type, %{status: :unpaid})
|
cycle = create_cycle(member, fee_type, %{status: :unpaid}, actor)
|
||||||
|
|
||||||
assert {:ok, updated} =
|
assert {:ok, updated} =
|
||||||
Ash.update(cycle, %{notes: "Waived due to special circumstances"},
|
Ash.update(cycle, %{notes: "Waived due to special circumstances"},
|
||||||
|
actor: actor,
|
||||||
action: :mark_as_suspended
|
action: :mark_as_suspended
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -131,42 +138,45 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
|
||||||
assert updated.notes == "Waived due to special circumstances"
|
assert updated.notes == "Waived due to special circumstances"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can change from paid to suspended" do
|
test "can change from paid to suspended", %{actor: actor} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||||
cycle = create_cycle(member, fee_type, %{status: :paid})
|
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
|
assert updated.status == :suspended
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "mark_as_unpaid" do
|
describe "mark_as_unpaid" do
|
||||||
test "sets status to :unpaid" do
|
test "sets status to :unpaid", %{actor: actor} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||||
cycle = create_cycle(member, fee_type, %{status: :paid})
|
cycle = create_cycle(member, fee_type, %{status: :paid}, actor)
|
||||||
|
|
||||||
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_unpaid)
|
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_unpaid)
|
||||||
assert updated.status == :unpaid
|
assert updated.status == :unpaid
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can set notes when marking as unpaid" do
|
test "can set notes when marking as unpaid", %{actor: actor} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||||
cycle = create_cycle(member, fee_type, %{status: :paid})
|
cycle = create_cycle(member, fee_type, %{status: :paid}, actor)
|
||||||
|
|
||||||
assert {:ok, updated} =
|
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.status == :unpaid
|
||||||
assert updated.notes == "Payment was reversed"
|
assert updated.notes == "Payment was reversed"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can change from suspended to unpaid" do
|
test "can change from suspended to unpaid", %{actor: actor} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||||
cycle = create_cycle(member, fee_type, %{status: :suspended})
|
cycle = create_cycle(member, fee_type, %{status: :suspended}, actor)
|
||||||
|
|
||||||
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_unpaid)
|
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_unpaid)
|
||||||
assert updated.status == :unpaid
|
assert updated.status == :unpaid
|
||||||
|
|
@ -174,33 +184,33 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "status transitions" do
|
describe "status transitions" do
|
||||||
test "all status transitions are allowed" do
|
test "all status transitions are allowed", %{actor: actor} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||||
|
|
||||||
# unpaid -> paid
|
# 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 {:ok, c1} = Ash.update(cycle1, %{}, actor: actor, action: :mark_as_paid)
|
||||||
assert c1.status == :paid
|
assert c1.status == :paid
|
||||||
|
|
||||||
# paid -> suspended
|
# paid -> suspended
|
||||||
assert {:ok, c2} = Ash.update(c1, %{}, action: :mark_as_suspended)
|
assert {:ok, c2} = Ash.update(c1, %{}, actor: actor, action: :mark_as_suspended)
|
||||||
assert c2.status == :suspended
|
assert c2.status == :suspended
|
||||||
|
|
||||||
# suspended -> unpaid
|
# suspended -> unpaid
|
||||||
assert {:ok, c3} = Ash.update(c2, %{}, action: :mark_as_unpaid)
|
assert {:ok, c3} = Ash.update(c2, %{}, actor: actor, action: :mark_as_unpaid)
|
||||||
assert c3.status == :unpaid
|
assert c3.status == :unpaid
|
||||||
|
|
||||||
# unpaid -> suspended
|
# unpaid -> suspended
|
||||||
assert {:ok, c4} = Ash.update(c3, %{}, action: :mark_as_suspended)
|
assert {:ok, c4} = Ash.update(c3, %{}, actor: actor, action: :mark_as_suspended)
|
||||||
assert c4.status == :suspended
|
assert c4.status == :suspended
|
||||||
|
|
||||||
# suspended -> paid
|
# suspended -> paid
|
||||||
assert {:ok, c5} = Ash.update(c4, %{}, action: :mark_as_paid)
|
assert {:ok, c5} = Ash.update(c4, %{}, actor: actor, action: :mark_as_paid)
|
||||||
assert c5.status == :paid
|
assert c5.status == :paid
|
||||||
|
|
||||||
# paid -> unpaid
|
# paid -> unpaid
|
||||||
assert {:ok, c6} = Ash.update(c5, %{}, action: :mark_as_unpaid)
|
assert {:ok, c6} = Ash.update(c5, %{}, actor: actor, action: :mark_as_unpaid)
|
||||||
assert c6.status == :unpaid
|
assert c6.status == :unpaid
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,13 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
%{actor: system_actor}
|
||||||
|
end
|
||||||
|
|
||||||
# Helper to create a membership fee type
|
# Helper to create a membership fee type
|
||||||
defp create_fee_type(attrs) do
|
defp create_fee_type(attrs, actor) do
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||||
amount: Decimal.new("50.00"),
|
amount: Decimal.new("50.00"),
|
||||||
|
|
@ -22,11 +27,11 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
|
||||||
|
|
||||||
MembershipFeeType
|
MembershipFeeType
|
||||||
|> Ash.Changeset.for_create(:create, attrs)
|
|> Ash.Changeset.for_create(:create, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "admin can create membership fee type" do
|
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 = %{
|
attrs = %{
|
||||||
name: "Standard Membership",
|
name: "Standard Membership",
|
||||||
amount: Decimal.new("120.00"),
|
amount: Decimal.new("120.00"),
|
||||||
|
|
@ -34,7 +39,8 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
|
||||||
description: "Standard yearly membership fee"
|
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 fee_type.name == "Standard Membership"
|
||||||
assert Decimal.equal?(fee_type.amount, Decimal.new("120.00"))
|
assert Decimal.equal?(fee_type.amount, Decimal.new("120.00"))
|
||||||
|
|
@ -44,88 +50,106 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "admin can update membership fee type" do
|
describe "admin can update membership fee type" do
|
||||||
setup do
|
setup %{actor: actor} do
|
||||||
{:ok, fee_type} =
|
{:ok, fee_type} =
|
||||||
Ash.create(MembershipFeeType, %{
|
Ash.create(
|
||||||
name: "Original Name",
|
MembershipFeeType,
|
||||||
amount: Decimal.new("100.00"),
|
%{
|
||||||
interval: :yearly,
|
name: "Original Name",
|
||||||
description: "Original description"
|
amount: Decimal.new("100.00"),
|
||||||
})
|
interval: :yearly,
|
||||||
|
description: "Original description"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
%{fee_type: fee_type}
|
%{fee_type: fee_type}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can update name", %{fee_type: fee_type} do
|
test "can update name", %{actor: actor, fee_type: fee_type} do
|
||||||
assert {:ok, updated} = Ash.update(fee_type, %{name: "Updated Name"})
|
assert {:ok, updated} = Ash.update(fee_type, %{name: "Updated Name"}, actor: actor)
|
||||||
assert updated.name == "Updated Name"
|
assert updated.name == "Updated Name"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can update amount", %{fee_type: fee_type} do
|
test "can update amount", %{actor: actor, fee_type: fee_type} do
|
||||||
assert {:ok, updated} = Ash.update(fee_type, %{amount: Decimal.new("150.00")})
|
assert {:ok, updated} = Ash.update(fee_type, %{amount: Decimal.new("150.00")}, actor: actor)
|
||||||
assert Decimal.equal?(updated.amount, Decimal.new("150.00"))
|
assert Decimal.equal?(updated.amount, Decimal.new("150.00"))
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can update description", %{fee_type: fee_type} do
|
test "can update description", %{actor: actor, fee_type: fee_type} do
|
||||||
assert {:ok, updated} = Ash.update(fee_type, %{description: "Updated description"})
|
assert {:ok, updated} =
|
||||||
|
Ash.update(fee_type, %{description: "Updated description"}, actor: actor)
|
||||||
|
|
||||||
assert updated.description == "Updated description"
|
assert updated.description == "Updated description"
|
||||||
end
|
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"
|
# Currently, interval is not in the accept list, so it's rejected as "NoSuchInput"
|
||||||
# After implementing validation, it should return a validation error
|
# 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)
|
# For now, check that it's an error (either NoSuchInput or validation error)
|
||||||
assert %Ash.Error.Invalid{} = error
|
assert %Ash.Error.Invalid{} = error
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "admin cannot delete membership fee type when in use" do
|
describe "admin cannot delete membership fee type when in use" do
|
||||||
test "cannot delete when members are assigned" do
|
test "cannot delete when members are assigned", %{actor: actor} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
|
||||||
# Create a member with this fee type
|
# Create a member with this fee type
|
||||||
{:ok, _member} =
|
{:ok, _member} =
|
||||||
Ash.create(Member, %{
|
Ash.create(
|
||||||
first_name: "Test",
|
Member,
|
||||||
last_name: "Member",
|
%{
|
||||||
email: "test.member.#{System.unique_integer([:positive])}@example.com",
|
first_name: "Test",
|
||||||
membership_fee_type_id: fee_type.id
|
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)
|
error_message = extract_error_message(error)
|
||||||
assert error_message =~ "member(s) are assigned"
|
assert error_message =~ "member(s) are assigned"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "cannot delete when cycles exist" do
|
test "cannot delete when cycles exist", %{actor: actor} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
|
||||||
# Create a member with this fee type
|
# Create a member with this fee type
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Ash.create(Member, %{
|
Ash.create(
|
||||||
first_name: "Test",
|
Member,
|
||||||
last_name: "Member",
|
%{
|
||||||
email: "test.member.#{System.unique_integer([:positive])}@example.com",
|
first_name: "Test",
|
||||||
membership_fee_type_id: fee_type.id
|
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
|
# Create a cycle for this fee type
|
||||||
{:ok, _cycle} =
|
{:ok, _cycle} =
|
||||||
Ash.create(MembershipFeeCycle, %{
|
Ash.create(
|
||||||
cycle_start: ~D[2025-01-01],
|
MembershipFeeCycle,
|
||||||
amount: Decimal.new("100.00"),
|
%{
|
||||||
member_id: member.id,
|
cycle_start: ~D[2025-01-01],
|
||||||
membership_fee_type_id: fee_type.id
|
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)
|
error_message = extract_error_message(error)
|
||||||
assert error_message =~ "cycle(s) reference"
|
assert error_message =~ "cycle(s) reference"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "cannot delete when used as default in settings" do
|
test "cannot delete when used as default in settings", %{actor: actor} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
|
||||||
# Set as default in settings
|
# Set as default in settings
|
||||||
{:ok, settings} = Mv.Membership.get_settings()
|
{:ok, settings} = Mv.Membership.get_settings()
|
||||||
|
|
@ -134,19 +158,19 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
|
||||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
||||||
default_membership_fee_type_id: fee_type.id
|
default_membership_fee_type_id: fee_type.id
|
||||||
})
|
})
|
||||||
|> Ash.update!()
|
|> Ash.update!(actor: actor)
|
||||||
|
|
||||||
# Try to delete
|
# 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)
|
error_message = extract_error_message(error)
|
||||||
assert error_message =~ "used as default in settings"
|
assert error_message =~ "used as default in settings"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "settings integration" do
|
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
|
# 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
|
# Set it as default in settings
|
||||||
{:ok, settings} = Mv.Membership.get_settings()
|
{:ok, settings} = Mv.Membership.get_settings()
|
||||||
|
|
@ -155,29 +179,33 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
|
||||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
||||||
default_membership_fee_type_id: fee_type.id
|
default_membership_fee_type_id: fee_type.id
|
||||||
})
|
})
|
||||||
|> Ash.update!()
|
|> Ash.update!(actor: actor)
|
||||||
|
|
||||||
# Create a member without explicitly setting membership_fee_type_id
|
# Create a member without explicitly setting membership_fee_type_id
|
||||||
# The Member resource automatically assigns the default_membership_fee_type_id
|
# The Member resource automatically assigns the default_membership_fee_type_id
|
||||||
# during creation via SetDefaultMembershipFeeType change.
|
# during creation via SetDefaultMembershipFeeType change.
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Ash.create(Member, %{
|
Ash.create(
|
||||||
first_name: "Test",
|
Member,
|
||||||
last_name: "Member",
|
%{
|
||||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
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
|
# Verify that the default membership fee type was automatically assigned
|
||||||
assert member.membership_fee_type_id == fee_type.id
|
assert member.membership_fee_type_id == fee_type.id
|
||||||
end
|
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
|
# This test verifies that the include_joining_cycle setting affects
|
||||||
# cycle generation. The actual cycle generation logic is tested in
|
# cycle generation. The actual cycle generation logic is tested in
|
||||||
# CycleGeneratorTest, but this integration test ensures the setting
|
# CycleGeneratorTest, but this integration test ensures the setting
|
||||||
# is properly used.
|
# is properly used.
|
||||||
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
|
||||||
# Set include_joining_cycle to false
|
# Set include_joining_cycle to false
|
||||||
{:ok, settings} = Mv.Membership.get_settings()
|
{:ok, settings} = Mv.Membership.get_settings()
|
||||||
|
|
@ -186,17 +214,21 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
|
||||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
||||||
include_joining_cycle: false
|
include_joining_cycle: false
|
||||||
})
|
})
|
||||||
|> Ash.update!()
|
|> Ash.update!(actor: actor)
|
||||||
|
|
||||||
# Create a member with join_date in the middle of a year
|
# Create a member with join_date in the middle of a year
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Ash.create(Member, %{
|
Ash.create(
|
||||||
first_name: "Test",
|
Member,
|
||||||
last_name: "Member",
|
%{
|
||||||
email: "test.member.#{System.unique_integer([:positive])}@example.com",
|
first_name: "Test",
|
||||||
join_date: ~D[2023-03-15],
|
last_name: "Member",
|
||||||
membership_fee_type_id: fee_type.id
|
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
|
# Verify that membership_fee_start_date was calculated correctly
|
||||||
# (should be 2024-01-01, not 2023-01-01, because include_joining_cycle = false)
|
# (should be 2024-01-01, not 2023-01-01, because include_joining_cycle = false)
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,13 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
|
||||||
|
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
|
|
||||||
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
%{actor: system_actor}
|
||||||
|
end
|
||||||
|
|
||||||
describe "create MembershipFeeType" do
|
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 = %{
|
attrs = %{
|
||||||
name: "Standard Membership",
|
name: "Standard Membership",
|
||||||
amount: Decimal.new("120.00"),
|
amount: Decimal.new("120.00"),
|
||||||
|
|
@ -16,7 +21,7 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
|
||||||
}
|
}
|
||||||
|
|
||||||
assert {:ok, %MembershipFeeType{} = fee_type} =
|
assert {:ok, %MembershipFeeType{} = fee_type} =
|
||||||
Ash.create(MembershipFeeType, attrs)
|
Ash.create(MembershipFeeType, attrs, actor: actor)
|
||||||
|
|
||||||
assert fee_type.name == "Standard Membership"
|
assert fee_type.name == "Standard Membership"
|
||||||
assert Decimal.equal?(fee_type.amount, Decimal.new("120.00"))
|
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"
|
assert fee_type.description == "Standard yearly membership fee"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can create membership fee type without description" do
|
test "can create membership fee type without description", %{actor: actor} do
|
||||||
attrs = %{
|
attrs = %{
|
||||||
name: "Basic",
|
name: "Basic",
|
||||||
amount: Decimal.new("60.00"),
|
amount: Decimal.new("60.00"),
|
||||||
interval: :monthly
|
interval: :monthly
|
||||||
}
|
}
|
||||||
|
|
||||||
assert {:ok, %MembershipFeeType{}} = Ash.create(MembershipFeeType, attrs)
|
assert {:ok, %MembershipFeeType{}} = Ash.create(MembershipFeeType, attrs, actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "requires name" do
|
test "requires name", %{actor: actor} do
|
||||||
attrs = %{
|
attrs = %{
|
||||||
amount: Decimal.new("100.00"),
|
amount: Decimal.new("100.00"),
|
||||||
interval: :yearly
|
interval: :yearly
|
||||||
}
|
}
|
||||||
|
|
||||||
assert {:error, error} = Ash.create(MembershipFeeType, attrs)
|
assert {:error, error} = Ash.create(MembershipFeeType, attrs, actor: actor)
|
||||||
assert error_on_field?(error, :name)
|
assert error_on_field?(error, :name)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "requires amount" do
|
test "requires amount", %{actor: actor} do
|
||||||
attrs = %{
|
attrs = %{
|
||||||
name: "Test Fee",
|
name: "Test Fee",
|
||||||
interval: :yearly
|
interval: :yearly
|
||||||
}
|
}
|
||||||
|
|
||||||
assert {:error, error} = Ash.create(MembershipFeeType, attrs)
|
assert {:error, error} = Ash.create(MembershipFeeType, attrs, actor: actor)
|
||||||
assert error_on_field?(error, :amount)
|
assert error_on_field?(error, :amount)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "requires interval" do
|
test "requires interval", %{actor: actor} do
|
||||||
attrs = %{
|
attrs = %{
|
||||||
name: "Test Fee",
|
name: "Test Fee",
|
||||||
amount: Decimal.new("100.00")
|
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)
|
assert error_on_field?(error, :interval)
|
||||||
end
|
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}
|
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
|
assert fee_type.interval == :monthly
|
||||||
end
|
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}
|
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
|
assert fee_type.interval == :quarterly
|
||||||
end
|
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}
|
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
|
assert fee_type.interval == :half_yearly
|
||||||
end
|
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}
|
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
|
assert fee_type.interval == :yearly
|
||||||
end
|
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}
|
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)
|
assert error_on_field?(error, :interval)
|
||||||
end
|
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}
|
attrs = %{name: "Unique Name", amount: Decimal.new("100.00"), interval: :yearly}
|
||||||
|
|
||||||
assert {:ok, _} = Ash.create(MembershipFeeType, attrs)
|
assert {:ok, _} = Ash.create(MembershipFeeType, attrs, actor: actor)
|
||||||
assert {:error, error} = Ash.create(MembershipFeeType, attrs)
|
assert {:error, error} = Ash.create(MembershipFeeType, attrs, actor: actor)
|
||||||
|
|
||||||
# Check for uniqueness error
|
# Check for uniqueness error
|
||||||
assert error_on_field?(error, :name)
|
assert error_on_field?(error, :name)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "rejects negative amount" do
|
test "rejects negative amount", %{actor: actor} do
|
||||||
attrs = %{name: "Negative Test", amount: Decimal.new("-10.00"), interval: :yearly}
|
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)
|
assert error_on_field?(error, :amount)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "accepts zero amount" do
|
test "accepts zero amount", %{actor: actor} do
|
||||||
attrs = %{name: "Zero Amount", amount: Decimal.new("0.00"), interval: :yearly}
|
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"))
|
assert Decimal.equal?(fee_type.amount, Decimal.new("0.00"))
|
||||||
end
|
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}
|
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"))
|
assert Decimal.equal?(fee_type.amount, Decimal.new("100.50"))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "update MembershipFeeType" do
|
describe "update MembershipFeeType" do
|
||||||
setup do
|
setup %{actor: actor} do
|
||||||
{:ok, fee_type} =
|
{:ok, fee_type} =
|
||||||
Ash.create(MembershipFeeType, %{
|
Ash.create(
|
||||||
name: "Original Name",
|
MembershipFeeType,
|
||||||
amount: Decimal.new("100.00"),
|
%{
|
||||||
interval: :yearly,
|
name: "Original Name",
|
||||||
description: "Original description"
|
amount: Decimal.new("100.00"),
|
||||||
})
|
interval: :yearly,
|
||||||
|
description: "Original description"
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
%{fee_type: fee_type}
|
%{fee_type: fee_type}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can update name", %{fee_type: fee_type} do
|
test "can update name", %{actor: actor, fee_type: fee_type} do
|
||||||
assert {:ok, updated} = Ash.update(fee_type, %{name: "Updated Name"})
|
assert {:ok, updated} = Ash.update(fee_type, %{name: "Updated Name"}, actor: actor)
|
||||||
assert updated.name == "Updated Name"
|
assert updated.name == "Updated Name"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can update amount", %{fee_type: fee_type} do
|
test "can update amount", %{actor: actor, fee_type: fee_type} do
|
||||||
assert {:ok, updated} = Ash.update(fee_type, %{amount: Decimal.new("150.00")})
|
assert {:ok, updated} = Ash.update(fee_type, %{amount: Decimal.new("150.00")}, actor: actor)
|
||||||
assert Decimal.equal?(updated.amount, Decimal.new("150.00"))
|
assert Decimal.equal?(updated.amount, Decimal.new("150.00"))
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can update description", %{fee_type: fee_type} do
|
test "can update description", %{actor: actor, fee_type: fee_type} do
|
||||||
assert {:ok, updated} = Ash.update(fee_type, %{description: "Updated description"})
|
assert {:ok, updated} =
|
||||||
|
Ash.update(fee_type, %{description: "Updated description"}, actor: actor)
|
||||||
|
|
||||||
assert updated.description == "Updated description"
|
assert updated.description == "Updated description"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can clear description", %{fee_type: fee_type} do
|
test "can clear description", %{actor: actor, fee_type: fee_type} do
|
||||||
assert {:ok, updated} = Ash.update(fee_type, %{description: nil})
|
assert {:ok, updated} = Ash.update(fee_type, %{description: nil}, actor: actor)
|
||||||
assert updated.description == nil
|
assert updated.description == nil
|
||||||
end
|
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"
|
# Currently, interval is not in the accept list, so it's rejected as "NoSuchInput"
|
||||||
# After implementing validation, it should return a validation error
|
# 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)
|
# For now, check that it's an error (either NoSuchInput or validation error)
|
||||||
assert %Ash.Error.Invalid{} = error
|
assert %Ash.Error.Invalid{} = error
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "delete MembershipFeeType" do
|
describe "delete MembershipFeeType" do
|
||||||
setup do
|
setup %{actor: actor} do
|
||||||
{:ok, fee_type} =
|
{:ok, fee_type} =
|
||||||
Ash.create(MembershipFeeType, %{
|
Ash.create(
|
||||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
MembershipFeeType,
|
||||||
amount: Decimal.new("100.00"),
|
%{
|
||||||
interval: :yearly
|
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||||
})
|
amount: Decimal.new("100.00"),
|
||||||
|
interval: :yearly
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
%{fee_type: fee_type}
|
%{fee_type: fee_type}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can delete when not in use", %{fee_type: fee_type} do
|
test "can delete when not in use", %{actor: actor, fee_type: fee_type} do
|
||||||
result = Ash.destroy(fee_type)
|
result = Ash.destroy(fee_type, actor: actor)
|
||||||
# Ash.destroy returns :ok or {:ok, _} depending on version
|
# Ash.destroy returns :ok or {:ok, _} depending on version
|
||||||
assert result == :ok or match?({:ok, _}, result)
|
assert result == :ok or match?({:ok, _}, result)
|
||||||
end
|
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
|
alias Mv.Membership.Member
|
||||||
|
|
||||||
# Create a member with this fee type
|
# Create a member with this fee type
|
||||||
{:ok, _member} =
|
{:ok, _member} =
|
||||||
Ash.create(Member, %{
|
Ash.create(
|
||||||
first_name: "Test",
|
Member,
|
||||||
last_name: "Member",
|
%{
|
||||||
email: "test.member.#{System.unique_integer([:positive])}@example.com",
|
first_name: "Test",
|
||||||
membership_fee_type_id: fee_type.id
|
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
|
# Check for either validation error message or DB constraint error
|
||||||
error_message = extract_error_message(error)
|
error_message = extract_error_message(error)
|
||||||
assert error_message =~ "member" or error_message =~ "referenced"
|
assert error_message =~ "member" or error_message =~ "referenced"
|
||||||
end
|
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.MembershipFees.MembershipFeeCycle
|
||||||
alias Mv.Membership.Member
|
alias Mv.Membership.Member
|
||||||
|
|
||||||
# Create a member with this fee type
|
# Create a member with this fee type
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Ash.create(Member, %{
|
Ash.create(
|
||||||
first_name: "Test",
|
Member,
|
||||||
last_name: "Member",
|
%{
|
||||||
email: "test.member.#{System.unique_integer([:positive])}@example.com",
|
first_name: "Test",
|
||||||
membership_fee_type_id: fee_type.id
|
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
|
# Create a cycle for this fee type
|
||||||
{:ok, _cycle} =
|
{:ok, _cycle} =
|
||||||
Ash.create(MembershipFeeCycle, %{
|
Ash.create(
|
||||||
cycle_start: ~D[2025-01-01],
|
MembershipFeeCycle,
|
||||||
amount: Decimal.new("100.00"),
|
%{
|
||||||
member_id: member.id,
|
cycle_start: ~D[2025-01-01],
|
||||||
membership_fee_type_id: fee_type.id
|
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
|
# Check for either validation error message or DB constraint error
|
||||||
error_message = extract_error_message(error)
|
error_message = extract_error_message(error)
|
||||||
assert error_message =~ "cycle" or error_message =~ "referenced"
|
assert error_message =~ "cycle" or error_message =~ "referenced"
|
||||||
end
|
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
|
# Set as default in settings
|
||||||
{:ok, settings} = Mv.Membership.get_settings()
|
{:ok, settings} = Mv.Membership.get_settings()
|
||||||
|
|
||||||
|
|
@ -237,10 +267,10 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
|
||||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
||||||
default_membership_fee_type_id: fee_type.id
|
default_membership_fee_type_id: fee_type.id
|
||||||
})
|
})
|
||||||
|> Ash.update!()
|
|> Ash.update!(actor: actor)
|
||||||
|
|
||||||
# Try to delete
|
# 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)
|
error_message = extract_error_message(error)
|
||||||
assert error_message =~ "used as default in settings"
|
assert error_message =~ "used as default in settings"
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -14,15 +14,23 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
||||||
|
|
||||||
require Ash.Query
|
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
|
# 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])}"
|
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
|
||||||
|
|
||||||
case Authorization.create_role(%{
|
case Authorization.create_role(
|
||||||
name: role_name,
|
%{
|
||||||
description: "Test role for #{permission_set_name}",
|
name: role_name,
|
||||||
permission_set_name: permission_set_name
|
description: "Test role for #{permission_set_name}",
|
||||||
}) do
|
permission_set_name: permission_set_name
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
) do
|
||||||
{:ok, role} -> role
|
{:ok, role} -> role
|
||||||
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
|
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
|
||||||
end
|
end
|
||||||
|
|
@ -30,9 +38,9 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
||||||
|
|
||||||
# Helper to create a user with a specific permission set
|
# Helper to create a user with a specific permission set
|
||||||
# Returns user with role preloaded (required for authorization)
|
# 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
|
# 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
|
# Create user
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
|
|
@ -41,39 +49,40 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
||||||
email: "user#{System.unique_integer([:positive])}@example.com",
|
email: "user#{System.unique_integer([:positive])}@example.com",
|
||||||
password: "testpassword123"
|
password: "testpassword123"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# Assign role to user
|
# Assign role to user
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
user
|
user
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|
||||||
|> Ash.update()
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
# Reload user with role preloaded (critical for authorization!)
|
# 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
|
user_with_role
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create another user (for testing access to other users)
|
# Helper to create another user (for testing access to other users)
|
||||||
defp create_other_user do
|
defp create_other_user(actor) do
|
||||||
create_user_with_permission_set("own_data")
|
create_user_with_permission_set("own_data", actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Shared test setup for permission sets with scope :own access
|
# Shared test setup for permission sets with scope :own access
|
||||||
defp setup_user_with_own_access(permission_set) do
|
defp setup_user_with_own_access(permission_set, actor) do
|
||||||
user = create_user_with_permission_set(permission_set)
|
user = create_user_with_permission_set(permission_set, actor)
|
||||||
other_user = create_other_user()
|
other_user = create_other_user(actor)
|
||||||
|
|
||||||
# Reload user to ensure role is preloaded
|
# 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}
|
%{user: user, other_user: other_user}
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "own_data permission set (Mitglied)" do
|
describe "own_data permission set (Mitglied)" do
|
||||||
setup do
|
setup %{actor: actor} do
|
||||||
setup_user_with_own_access("own_data")
|
setup_user_with_own_access("own_data", actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can read own user record", %{user: user} do
|
test "can read own user record", %{user: user} do
|
||||||
|
|
@ -140,8 +149,8 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "read_only permission set (Vorstand/Buchhaltung)" do
|
describe "read_only permission set (Vorstand/Buchhaltung)" do
|
||||||
setup do
|
setup %{actor: actor} do
|
||||||
setup_user_with_own_access("read_only")
|
setup_user_with_own_access("read_only", actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can read own user record", %{user: user} do
|
test "can read own user record", %{user: user} do
|
||||||
|
|
@ -208,8 +217,8 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "normal_user permission set (Kassenwart)" do
|
describe "normal_user permission set (Kassenwart)" do
|
||||||
setup do
|
setup %{actor: actor} do
|
||||||
setup_user_with_own_access("normal_user")
|
setup_user_with_own_access("normal_user", actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can read own user record", %{user: user} do
|
test "can read own user record", %{user: user} do
|
||||||
|
|
@ -276,12 +285,13 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "admin permission set" do
|
describe "admin permission set" do
|
||||||
setup do
|
setup %{actor: actor} do
|
||||||
user = create_user_with_permission_set("admin")
|
user = create_user_with_permission_set("admin", actor)
|
||||||
other_user = create_other_user()
|
other_user = create_other_user(actor)
|
||||||
|
|
||||||
# Reload user to ensure role is preloaded
|
# 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}
|
%{user: user, other_user: other_user}
|
||||||
end
|
end
|
||||||
|
|
@ -333,21 +343,24 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "AshAuthentication bypass" do
|
describe "AshAuthentication bypass" do
|
||||||
test "register_with_password works without actor" do
|
test "register_with_password works without actor via AshAuthentication bypass" do
|
||||||
# Registration should work without actor (AshAuthentication bypass)
|
# Test that AshAuthentication bypass allows registration without actor
|
||||||
{:ok, user} =
|
# This tests the actual bypass mechanism, not admin permissions
|
||||||
|
changeset =
|
||||||
Accounts.User
|
Accounts.User
|
||||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||||
email: "register#{System.unique_integer([:positive])}@example.com",
|
email: "register#{System.unique_integer([:positive])}@example.com",
|
||||||
password: "testpassword123"
|
password: "testpassword123"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.Changeset.set_context(%{private: %{ash_authentication?: true}})
|
||||||
|
|
||||||
|
{:ok, user} = Ash.create(changeset)
|
||||||
|
|
||||||
assert user.email
|
assert user.email
|
||||||
end
|
end
|
||||||
|
|
||||||
test "register_with_rauthy works with OIDC user_info" do
|
test "register_with_rauthy works without actor via AshAuthentication bypass" do
|
||||||
# OIDC registration should work (AshAuthentication bypass)
|
# Test that AshAuthentication bypass allows OIDC registration without actor
|
||||||
user_info = %{
|
user_info = %{
|
||||||
"sub" => "oidc_sub_#{System.unique_integer([:positive])}",
|
"sub" => "oidc_sub_#{System.unique_integer([:positive])}",
|
||||||
"email" => "oidc#{System.unique_integer([:positive])}@example.com"
|
"email" => "oidc#{System.unique_integer([:positive])}@example.com"
|
||||||
|
|
@ -355,20 +368,24 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
||||||
|
|
||||||
oauth_tokens = %{access_token: "token", refresh_token: "refresh"}
|
oauth_tokens = %{access_token: "token", refresh_token: "refresh"}
|
||||||
|
|
||||||
{:ok, user} =
|
changeset =
|
||||||
Accounts.User
|
Accounts.User
|
||||||
|> Ash.Changeset.for_create(:register_with_rauthy, %{
|
|> Ash.Changeset.for_create(:register_with_rauthy, %{
|
||||||
user_info: user_info,
|
user_info: user_info,
|
||||||
oauth_tokens: oauth_tokens
|
oauth_tokens: oauth_tokens
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.Changeset.set_context(%{private: %{ash_authentication?: true}})
|
||||||
|
|
||||||
|
{:ok, user} = Ash.create(changeset)
|
||||||
|
|
||||||
assert user.email
|
assert user.email
|
||||||
assert user.oidc_id == user_info["sub"]
|
assert user.oidc_id == user_info["sub"]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "sign_in_with_rauthy works with OIDC user_info" do
|
test "sign_in_with_rauthy works without actor via AshAuthentication bypass" do
|
||||||
# First create a user with OIDC ID
|
# First create a user with OIDC ID (using system_actor for setup)
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
user_info_create = %{
|
user_info_create = %{
|
||||||
"sub" => "oidc_sub_#{System.unique_integer([:positive])}",
|
"sub" => "oidc_sub_#{System.unique_integer([:positive])}",
|
||||||
"email" => "oidc#{System.unique_integer([:positive])}@example.com"
|
"email" => "oidc#{System.unique_integer([:positive])}@example.com"
|
||||||
|
|
@ -382,16 +399,18 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
||||||
user_info: user_info_create,
|
user_info: user_info_create,
|
||||||
oauth_tokens: oauth_tokens
|
oauth_tokens: oauth_tokens
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Now test sign_in_with_rauthy (should work via AshAuthentication bypass)
|
# Now test sign_in_with_rauthy without actor (should work via AshAuthentication bypass)
|
||||||
{:ok, signed_in_user} =
|
query =
|
||||||
Accounts.User
|
Accounts.User
|
||||||
|> Ash.Query.for_read(:sign_in_with_rauthy, %{
|
|> Ash.Query.for_read(:sign_in_with_rauthy, %{
|
||||||
user_info: user_info_create,
|
user_info: user_info_create,
|
||||||
oauth_tokens: oauth_tokens
|
oauth_tokens: oauth_tokens
|
||||||
})
|
})
|
||||||
|> Ash.read_one()
|
|> Ash.Query.set_context(%{private: %{ash_authentication?: true}})
|
||||||
|
|
||||||
|
{:ok, signed_in_user} = Ash.read_one(query)
|
||||||
|
|
||||||
assert signed_in_user.id == user.id
|
assert signed_in_user.id == user.id
|
||||||
end
|
end
|
||||||
|
|
@ -403,22 +422,4 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
||||||
# when called through the proper authentication flow (sign_in, token refresh, etc.).
|
# when called through the proper authentication flow (sign_in, token refresh, etc.).
|
||||||
# Integration tests that use actual JWT tokens cover this functionality.
|
# Integration tests that use actual JWT tokens cover this functionality.
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,17 @@ defmodule Mv.Authorization.ActorTest do
|
||||||
alias Mv.Accounts
|
alias Mv.Accounts
|
||||||
alias Mv.Authorization.Actor
|
alias Mv.Authorization.Actor
|
||||||
|
|
||||||
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
%{actor: system_actor}
|
||||||
|
end
|
||||||
|
|
||||||
describe "ensure_loaded/1" do
|
describe "ensure_loaded/1" do
|
||||||
test "returns nil when actor is nil" do
|
test "returns nil when actor is nil" do
|
||||||
assert Actor.ensure_loaded(nil) == nil
|
assert Actor.ensure_loaded(nil) == nil
|
||||||
end
|
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
|
# Create user with role
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Accounts.User
|
Accounts.User
|
||||||
|
|
@ -20,10 +25,10 @@ defmodule Mv.Authorization.ActorTest do
|
||||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||||
password: "testpassword123"
|
password: "testpassword123"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# Load role
|
# 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)
|
# Should return as-is (no additional load)
|
||||||
result = Actor.ensure_loaded(user_with_role)
|
result = Actor.ensure_loaded(user_with_role)
|
||||||
|
|
@ -31,7 +36,7 @@ defmodule Mv.Authorization.ActorTest do
|
||||||
assert result.role != %Ash.NotLoaded{}
|
assert result.role != %Ash.NotLoaded{}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "loads role when it's NotLoaded" do
|
test "loads role when it's NotLoaded", %{actor: actor} do
|
||||||
# Create a role first
|
# Create a role first
|
||||||
{:ok, role} =
|
{:ok, role} =
|
||||||
Mv.Authorization.Role
|
Mv.Authorization.Role
|
||||||
|
|
@ -40,7 +45,7 @@ defmodule Mv.Authorization.ActorTest do
|
||||||
description: "Test role",
|
description: "Test role",
|
||||||
permission_set_name: "own_data"
|
permission_set_name: "own_data"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# Create user with role
|
# Create user with role
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
|
|
@ -49,18 +54,18 @@ defmodule Mv.Authorization.ActorTest do
|
||||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||||
password: "testpassword123"
|
password: "testpassword123"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# Assign role to user
|
# Assign role to user
|
||||||
{:ok, user_with_role} =
|
{:ok, user_with_role} =
|
||||||
user
|
user
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|
|> 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)
|
# Fetch user again WITHOUT loading role (simulates "role not preloaded" scenario)
|
||||||
{:ok, user_without_role_loaded} =
|
{: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)
|
# User has role as NotLoaded (relationship not preloaded)
|
||||||
assert match?(%Ash.NotLoaded{}, user_without_role_loaded.role)
|
assert match?(%Ash.NotLoaded{}, user_without_role_loaded.role)
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,8 @@ defmodule Mv.Authorization.Checks.HasPermissionFailClosedTest do
|
||||||
|> Ash.Query.new()
|
|> Ash.Query.new()
|
||||||
|> Ash.Query.filter_input(deny_filter)
|
|> 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: deny-filter must match nothing
|
||||||
assert results == []
|
assert results == []
|
||||||
|
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
defmodule Mv.Authorization.Checks.NoActorTest do
|
|
||||||
@moduledoc """
|
|
||||||
Tests for the NoActor Ash Policy Check.
|
|
||||||
|
|
||||||
This check allows actions without an actor ONLY in test environment.
|
|
||||||
In production/dev, all operations without an actor are denied.
|
|
||||||
"""
|
|
||||||
use ExUnit.Case, async: true
|
|
||||||
|
|
||||||
alias Mv.Authorization.Checks.NoActor
|
|
||||||
|
|
||||||
describe "match?/3" do
|
|
||||||
test "returns true when actor is nil in test environment" do
|
|
||||||
# In test environment (config :allow_no_actor_bypass = true), NoActor allows operations
|
|
||||||
result = NoActor.match?(nil, %{}, [])
|
|
||||||
assert result == true
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns false when actor is present" do
|
|
||||||
actor = %{id: "user-123"}
|
|
||||||
result = NoActor.match?(actor, %{}, [])
|
|
||||||
assert result == false
|
|
||||||
end
|
|
||||||
|
|
||||||
test "uses compile-time config (not runtime Mix.env)" do
|
|
||||||
# The @allow_no_actor_bypass is set via Application.compile_env at compile time
|
|
||||||
# In test.exs: config :mv, :allow_no_actor_bypass, true
|
|
||||||
# In prod/dev: not set (defaults to false)
|
|
||||||
# This ensures the check is release-safe (no runtime Mix.env dependency)
|
|
||||||
result = NoActor.match?(nil, %{}, [])
|
|
||||||
|
|
||||||
# In test environment (as compiled), should allow
|
|
||||||
assert result == true
|
|
||||||
|
|
||||||
# Note: We cannot test "production mode" here because the flag is compile-time.
|
|
||||||
# Production safety is guaranteed by:
|
|
||||||
# 1. Config only set in test.exs
|
|
||||||
# 2. Default is false (fail-closed)
|
|
||||||
# 3. No runtime environment checks
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "describe/1" do
|
|
||||||
test "returns description based on compile-time config" do
|
|
||||||
description = NoActor.describe([])
|
|
||||||
assert is_binary(description)
|
|
||||||
|
|
||||||
# In test environment (compiled with :allow_no_actor_bypass = true)
|
|
||||||
assert description =~ "test environment"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -6,6 +6,11 @@ defmodule Mv.Authorization.RoleTest do
|
||||||
|
|
||||||
alias Mv.Authorization
|
alias Mv.Authorization
|
||||||
|
|
||||||
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
%{actor: system_actor}
|
||||||
|
end
|
||||||
|
|
||||||
describe "permission_set_name validation" do
|
describe "permission_set_name validation" do
|
||||||
test "accepts valid permission set names" do
|
test "accepts valid permission set names" do
|
||||||
attrs = %{
|
attrs = %{
|
||||||
|
|
@ -42,7 +47,7 @@ defmodule Mv.Authorization.RoleTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "system role deletion protection" do
|
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
|
# is_system_role is not settable via public API, so we use Ash.Changeset directly
|
||||||
changeset =
|
changeset =
|
||||||
Mv.Authorization.Role
|
Mv.Authorization.Role
|
||||||
|
|
@ -52,7 +57,7 @@ defmodule Mv.Authorization.RoleTest do
|
||||||
})
|
})
|
||||||
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|
|> 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}} =
|
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
||||||
Authorization.destroy_role(system_role)
|
Authorization.destroy_role(system_role)
|
||||||
|
|
|
||||||
|
|
@ -43,51 +43,55 @@ defmodule Mv.Helpers.SystemActorTest do
|
||||||
|
|
||||||
# Helper function to ensure system user exists with admin role
|
# Helper function to ensure system user exists with admin role
|
||||||
defp ensure_system_user(admin_role) do
|
defp ensure_system_user(admin_role) do
|
||||||
|
# Use authorize?: false for bootstrap operations
|
||||||
case Accounts.User
|
case Accounts.User
|
||||||
|> Ash.Query.filter(email == ^"system@mila.local")
|
|> 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) ->
|
{:ok, user} when not is_nil(user) ->
|
||||||
user
|
user
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|> Ash.update!()
|
|> Ash.update!(authorize?: false)
|
||||||
|> Ash.load!(:role, domain: Mv.Accounts)
|
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
Accounts.create_user!(%{email: "system@mila.local"},
|
Accounts.create_user!(%{email: "system@mila.local"},
|
||||||
upsert?: true,
|
upsert?: true,
|
||||||
upsert_identity: :unique_email
|
upsert_identity: :unique_email,
|
||||||
|
authorize?: false
|
||||||
)
|
)
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|> Ash.update!()
|
|> Ash.update!(authorize?: false)
|
||||||
|> Ash.load!(:role, domain: Mv.Accounts)
|
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper function to ensure admin user exists with admin role
|
# Helper function to ensure admin user exists with admin role
|
||||||
defp ensure_admin_user(admin_role) do
|
defp ensure_admin_user(admin_role) do
|
||||||
|
# Use authorize?: false for bootstrap operations
|
||||||
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
||||||
|
|
||||||
case Accounts.User
|
case Accounts.User
|
||||||
|> Ash.Query.filter(email == ^admin_email)
|
|> 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) ->
|
{:ok, user} when not is_nil(user) ->
|
||||||
user
|
user
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|> Ash.update!()
|
|> Ash.update!(authorize?: false)
|
||||||
|> Ash.load!(:role, domain: Mv.Accounts)
|
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
Accounts.create_user!(%{email: admin_email},
|
Accounts.create_user!(%{email: admin_email},
|
||||||
upsert?: true,
|
upsert?: true,
|
||||||
upsert_identity: :unique_email
|
upsert_identity: :unique_email,
|
||||||
|
authorize?: false
|
||||||
)
|
)
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|> Ash.update!()
|
|> Ash.update!(authorize?: false)
|
||||||
|> Ash.load!(:role, domain: Mv.Accounts)
|
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
|
||||||
end
|
end
|
||||||
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
|
test "falls back to admin user if system user doesn't exist", %{admin_user: _admin_user} do
|
||||||
# Delete system user if it exists
|
# Delete system user if it exists
|
||||||
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
|
||||||
case Accounts.User
|
case Accounts.User
|
||||||
|> Ash.Query.filter(email == ^"system@mila.local")
|
|> 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) ->
|
{:ok, user} when not is_nil(user) ->
|
||||||
Ash.destroy!(user, domain: Mv.Accounts)
|
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
:ok
|
: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
|
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
|
# In test environment, system actor should auto-create if missing
|
||||||
# Delete all users to test auto-creation
|
# Delete all users to test auto-creation
|
||||||
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
|
||||||
case Accounts.User
|
case Accounts.User
|
||||||
|> Ash.Query.filter(email == ^"system@mila.local")
|
|> 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) ->
|
{:ok, user} when not is_nil(user) ->
|
||||||
Ash.destroy!(user, domain: Mv.Accounts)
|
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
:ok
|
:ok
|
||||||
|
|
@ -163,11 +171,13 @@ defmodule Mv.Helpers.SystemActorTest do
|
||||||
|
|
||||||
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
||||||
|
|
||||||
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
|
||||||
case Accounts.User
|
case Accounts.User
|
||||||
|> Ash.Query.filter(email == ^admin_email)
|
|> 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) ->
|
{:ok, user} when not is_nil(user) ->
|
||||||
Ash.destroy!(user, domain: Mv.Accounts)
|
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
:ok
|
:ok
|
||||||
|
|
@ -211,11 +221,13 @@ defmodule Mv.Helpers.SystemActorTest do
|
||||||
|
|
||||||
test "returns error tuple when system actor cannot be loaded" do
|
test "returns error tuple when system actor cannot be loaded" do
|
||||||
# Delete all users to force error
|
# Delete all users to force error
|
||||||
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
|
||||||
case Accounts.User
|
case Accounts.User
|
||||||
|> Ash.Query.filter(email == ^"system@mila.local")
|
|> 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) ->
|
{:ok, user} when not is_nil(user) ->
|
||||||
Ash.destroy!(user, domain: Mv.Accounts)
|
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
:ok
|
:ok
|
||||||
|
|
@ -223,11 +235,13 @@ defmodule Mv.Helpers.SystemActorTest do
|
||||||
|
|
||||||
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
||||||
|
|
||||||
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
|
||||||
case Accounts.User
|
case Accounts.User
|
||||||
|> Ash.Query.filter(email == ^admin_email)
|
|> 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) ->
|
{:ok, user} when not is_nil(user) ->
|
||||||
Ash.destroy!(user, domain: Mv.Accounts)
|
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
:ok
|
:ok
|
||||||
|
|
@ -252,18 +266,22 @@ defmodule Mv.Helpers.SystemActorTest do
|
||||||
|
|
||||||
describe "edge cases" do
|
describe "edge cases" do
|
||||||
test "raises error if admin user has no role", %{admin_user: admin_user} 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
|
# Remove role from admin user
|
||||||
admin_user
|
admin_user
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|> Ash.Changeset.manage_relationship(:role, nil, type: :append_and_remove)
|
|> Ash.Changeset.manage_relationship(:role, nil, type: :append_and_remove)
|
||||||
|> Ash.update!()
|
|> Ash.update!(actor: system_actor)
|
||||||
|
|
||||||
# Delete system user to force fallback
|
# Delete system user to force fallback
|
||||||
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
|
||||||
case Accounts.User
|
case Accounts.User
|
||||||
|> Ash.Query.filter(email == ^"system@mila.local")
|
|> 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) ->
|
{:ok, user} when not is_nil(user) ->
|
||||||
Ash.destroy!(user, domain: Mv.Accounts)
|
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
:ok
|
:ok
|
||||||
|
|
@ -279,11 +297,13 @@ defmodule Mv.Helpers.SystemActorTest do
|
||||||
|
|
||||||
test "handles concurrent calls without race conditions" do
|
test "handles concurrent calls without race conditions" do
|
||||||
# Delete system user and admin user to force creation
|
# Delete system user and admin user to force creation
|
||||||
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
|
||||||
case Accounts.User
|
case Accounts.User
|
||||||
|> Ash.Query.filter(email == ^"system@mila.local")
|
|> 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) ->
|
{:ok, user} when not is_nil(user) ->
|
||||||
Ash.destroy!(user, domain: Mv.Accounts)
|
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
:ok
|
:ok
|
||||||
|
|
@ -291,11 +311,13 @@ defmodule Mv.Helpers.SystemActorTest do
|
||||||
|
|
||||||
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
||||||
|
|
||||||
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
|
||||||
case Accounts.User
|
case Accounts.User
|
||||||
|> Ash.Query.filter(email == ^admin_email)
|
|> 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) ->
|
{:ok, user} when not is_nil(user) ->
|
||||||
Ash.destroy!(user, domain: Mv.Accounts)
|
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
:ok
|
:ok
|
||||||
|
|
@ -330,11 +352,13 @@ defmodule Mv.Helpers.SystemActorTest do
|
||||||
permission_set_name: "read_only"
|
permission_set_name: "read_only"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Assign wrong role to system user
|
# Assign wrong role to system user
|
||||||
system_user
|
system_user
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|> Ash.Changeset.manage_relationship(:role, read_only_role, type: :append_and_remove)
|
|> Ash.Changeset.manage_relationship(:role, read_only_role, type: :append_and_remove)
|
||||||
|> Ash.update!()
|
|> Ash.update!(actor: system_actor)
|
||||||
|
|
||||||
SystemActor.invalidate_cache()
|
SystemActor.invalidate_cache()
|
||||||
|
|
||||||
|
|
@ -345,11 +369,13 @@ defmodule Mv.Helpers.SystemActorTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "raises error if system user has no role", %{system_user: system_user} do
|
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
|
# Remove role from system user
|
||||||
system_user
|
system_user
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|> Ash.Changeset.manage_relationship(:role, nil, type: :append_and_remove)
|
|> Ash.Changeset.manage_relationship(:role, nil, type: :append_and_remove)
|
||||||
|> Ash.update!()
|
|> Ash.update!(actor: system_actor)
|
||||||
|
|
||||||
SystemActor.invalidate_cache()
|
SystemActor.invalidate_cache()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,8 @@ defmodule Mv.Membership.Import.MemberCSVTest do
|
||||||
assert chunk_result.errors == []
|
assert chunk_result.errors == []
|
||||||
|
|
||||||
# Verify member was created
|
# 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"))
|
assert Enum.any?(members, &(&1.email == "john@example.com"))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -174,8 +175,12 @@ defmodule Mv.Membership.Import.MemberCSVTest do
|
||||||
|
|
||||||
test "returns error for duplicate email" do
|
test "returns error for duplicate email" do
|
||||||
# Create existing member first
|
# Create existing member first
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
{:ok, _existing} =
|
{: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 = [
|
chunk_rows_with_lines = [
|
||||||
{2, %{member: %{email: "duplicate@example.com", first_name: "New"}, custom: %{}}}
|
{2, %{member: %{email: "duplicate@example.com", first_name: "New"}, custom: %{}}}
|
||||||
|
|
@ -199,6 +204,8 @@ defmodule Mv.Membership.Import.MemberCSVTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "creates member with custom field values" do
|
test "creates member with custom field values" do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Create custom field first
|
# Create custom field first
|
||||||
{:ok, custom_field} =
|
{:ok, custom_field} =
|
||||||
Mv.Membership.CustomField
|
Mv.Membership.CustomField
|
||||||
|
|
@ -206,7 +213,7 @@ defmodule Mv.Membership.Import.MemberCSVTest do
|
||||||
name: "Phone",
|
name: "Phone",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
chunk_rows_with_lines = [
|
chunk_rows_with_lines = [
|
||||||
{2,
|
{2,
|
||||||
|
|
@ -232,7 +239,8 @@ defmodule Mv.Membership.Import.MemberCSVTest do
|
||||||
assert chunk_result.failed == 0
|
assert chunk_result.failed == 0
|
||||||
|
|
||||||
# Verify member and custom field value were created
|
# 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"))
|
member = Enum.find(members, &(&1.email == "withcustom@example.com"))
|
||||||
assert member != nil
|
assert member != nil
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,15 +16,23 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
||||||
|
|
||||||
require Ash.Query
|
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
|
# 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])}"
|
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
|
||||||
|
|
||||||
case Authorization.create_role(%{
|
case Authorization.create_role(
|
||||||
name: role_name,
|
%{
|
||||||
description: "Test role for #{permission_set_name}",
|
name: role_name,
|
||||||
permission_set_name: permission_set_name
|
description: "Test role for #{permission_set_name}",
|
||||||
}) do
|
permission_set_name: permission_set_name
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
) do
|
||||||
{:ok, role} -> role
|
{:ok, role} -> role
|
||||||
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
|
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
|
||||||
end
|
end
|
||||||
|
|
@ -32,9 +40,9 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
||||||
|
|
||||||
# Helper to create a user with a specific permission set
|
# Helper to create a user with a specific permission set
|
||||||
# Returns user with role preloaded (required for authorization)
|
# 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
|
# 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
|
# Create user
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
|
|
@ -43,28 +51,28 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
||||||
email: "user#{System.unique_integer([:positive])}@example.com",
|
email: "user#{System.unique_integer([:positive])}@example.com",
|
||||||
password: "testpassword123"
|
password: "testpassword123"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# Assign role to user
|
# Assign role to user
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
user
|
user
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|
||||||
|> Ash.update()
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
# Reload user with role preloaded (critical for authorization!)
|
# 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
|
user_with_role
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create an admin user (for creating test fixtures)
|
# Helper to create an admin user (for creating test fixtures)
|
||||||
defp create_admin_user do
|
defp create_admin_user(actor) do
|
||||||
create_user_with_permission_set("admin")
|
create_user_with_permission_set("admin", actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a member linked to a user
|
# Helper to create a member linked to a user
|
||||||
defp create_linked_member_for_user(user) do
|
defp create_linked_member_for_user(user, actor) do
|
||||||
admin = create_admin_user()
|
admin = create_admin_user(actor)
|
||||||
|
|
||||||
# Create member
|
# Create member
|
||||||
# NOTE: We need to ensure the member is actually persisted to the database
|
# NOTE: We need to ensure the member is actually persisted to the database
|
||||||
|
|
@ -96,8 +104,8 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create an unlinked member (no user relationship)
|
# Helper to create an unlinked member (no user relationship)
|
||||||
defp create_unlinked_member do
|
defp create_unlinked_member(actor) do
|
||||||
admin = create_admin_user()
|
admin = create_admin_user(actor)
|
||||||
|
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.Member
|
Membership.Member
|
||||||
|
|
@ -112,14 +120,16 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "own_data permission set (Mitglied)" do
|
describe "own_data permission set (Mitglied)" do
|
||||||
setup do
|
setup %{actor: actor} do
|
||||||
user = create_user_with_permission_set("own_data")
|
user = create_user_with_permission_set("own_data", actor)
|
||||||
linked_member = create_linked_member_for_user(user)
|
linked_member = create_linked_member_for_user(user, actor)
|
||||||
unlinked_member = create_unlinked_member()
|
unlinked_member = create_unlinked_member(actor)
|
||||||
|
|
||||||
# Reload user to get updated member_id
|
# Reload user to get updated member_id
|
||||||
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
|
{:ok, user} =
|
||||||
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts)
|
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}
|
%{user: user, linked_member: linked_member, unlinked_member: unlinked_member}
|
||||||
end
|
end
|
||||||
|
|
@ -165,7 +175,10 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
||||||
end
|
end
|
||||||
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)
|
{:ok, members} = Ash.read(Membership.Member, actor: user, domain: Mv.Membership)
|
||||||
|
|
||||||
# Should only return the linked member (scope :linked filters)
|
# Should only return the linked member (scope :linked filters)
|
||||||
|
|
@ -185,7 +198,10 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
||||||
end
|
end
|
||||||
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 ->
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
Ash.destroy!(linked_member, actor: user)
|
Ash.destroy!(linked_member, actor: user)
|
||||||
end
|
end
|
||||||
|
|
@ -193,13 +209,14 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "read_only permission set (Vorstand/Buchhaltung)" do
|
describe "read_only permission set (Vorstand/Buchhaltung)" do
|
||||||
setup do
|
setup %{actor: actor} do
|
||||||
user = create_user_with_permission_set("read_only")
|
user = create_user_with_permission_set("read_only", actor)
|
||||||
linked_member = create_linked_member_for_user(user)
|
linked_member = create_linked_member_for_user(user, actor)
|
||||||
unlinked_member = create_unlinked_member()
|
unlinked_member = create_unlinked_member(actor)
|
||||||
|
|
||||||
# Reload user to get updated member_id
|
# 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}
|
%{user: user, linked_member: linked_member, unlinked_member: unlinked_member}
|
||||||
end
|
end
|
||||||
|
|
@ -217,7 +234,10 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
||||||
assert unlinked_member.id in member_ids
|
assert unlinked_member.id in member_ids
|
||||||
end
|
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} =
|
{:ok, member} =
|
||||||
Ash.get(Membership.Member, unlinked_member.id, actor: user, domain: Mv.Membership)
|
Ash.get(Membership.Member, unlinked_member.id, actor: user, domain: Mv.Membership)
|
||||||
|
|
||||||
|
|
@ -258,13 +278,14 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "normal_user permission set (Kassenwart)" do
|
describe "normal_user permission set (Kassenwart)" do
|
||||||
setup do
|
setup %{actor: actor} do
|
||||||
user = create_user_with_permission_set("normal_user")
|
user = create_user_with_permission_set("normal_user", actor)
|
||||||
linked_member = create_linked_member_for_user(user)
|
linked_member = create_linked_member_for_user(user, actor)
|
||||||
unlinked_member = create_unlinked_member()
|
unlinked_member = create_unlinked_member(actor)
|
||||||
|
|
||||||
# Reload user to get updated member_id
|
# 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}
|
%{user: user, linked_member: linked_member, unlinked_member: unlinked_member}
|
||||||
end
|
end
|
||||||
|
|
@ -315,13 +336,14 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "admin permission set" do
|
describe "admin permission set" do
|
||||||
setup do
|
setup %{actor: actor} do
|
||||||
user = create_user_with_permission_set("admin")
|
user = create_user_with_permission_set("admin", actor)
|
||||||
linked_member = create_linked_member_for_user(user)
|
linked_member = create_linked_member_for_user(user, actor)
|
||||||
unlinked_member = create_unlinked_member()
|
unlinked_member = create_unlinked_member(actor)
|
||||||
|
|
||||||
# Reload user to get updated member_id
|
# 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}
|
%{user: user, linked_member: linked_member, unlinked_member: unlinked_member}
|
||||||
end
|
end
|
||||||
|
|
@ -361,7 +383,10 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
||||||
assert updated_member.first_name == "Updated"
|
assert updated_member.first_name == "Updated"
|
||||||
end
|
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)
|
:ok = Ash.destroy(unlinked_member, actor: user)
|
||||||
|
|
||||||
# Verify member is deleted
|
# Verify member is deleted
|
||||||
|
|
@ -370,19 +395,24 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "special case: user can always READ linked member" do
|
describe "special case: user can always READ linked member" do
|
||||||
# Note: The special case policy only applies to :read actions.
|
setup %{actor: _actor} do
|
||||||
# Updates are handled by HasPermission with :linked scope (if permission exists).
|
# 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
|
# 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.
|
# 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.
|
# This test verifies the special case works independently of permission sets.
|
||||||
user = create_user_with_permission_set("read_only")
|
user = create_user_with_permission_set("read_only", actor)
|
||||||
linked_member = create_linked_member_for_user(user)
|
linked_member = create_linked_member_for_user(user, actor)
|
||||||
|
|
||||||
# Reload user to get updated member_id
|
# Reload user to get updated member_id
|
||||||
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
|
{:ok, user} =
|
||||||
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts)
|
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)
|
# Should succeed (special case bypass policy for :read takes precedence)
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
|
|
@ -391,15 +421,17 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
||||||
assert member.id == linked_member.id
|
assert member.id == linked_member.id
|
||||||
end
|
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
|
# own_data has Member.read scope :linked, but the special case ensures
|
||||||
# users can ALWAYS read their linked member regardless of permission set.
|
# users can ALWAYS read their linked member regardless of permission set.
|
||||||
user = create_user_with_permission_set("own_data")
|
user = create_user_with_permission_set("own_data", actor)
|
||||||
linked_member = create_linked_member_for_user(user)
|
linked_member = create_linked_member_for_user(user, actor)
|
||||||
|
|
||||||
# Reload user to get updated member_id
|
# Reload user to get updated member_id
|
||||||
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
|
{:ok, user} =
|
||||||
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts)
|
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)
|
# Should succeed (special case bypass policy for :read takes precedence)
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
|
|
@ -408,15 +440,19 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
||||||
assert member.id == linked_member.id
|
assert member.id == linked_member.id
|
||||||
end
|
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
|
# Update is NOT handled by special case - it's handled by HasPermission
|
||||||
# with :linked scope. own_data has Member.update scope :linked.
|
# with :linked scope. own_data has Member.update scope :linked.
|
||||||
user = create_user_with_permission_set("own_data")
|
user = create_user_with_permission_set("own_data", actor)
|
||||||
linked_member = create_linked_member_for_user(user)
|
linked_member = create_linked_member_for_user(user, actor)
|
||||||
|
|
||||||
# Reload user to get updated member_id
|
# Reload user to get updated member_id
|
||||||
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
|
{:ok, user} =
|
||||||
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts)
|
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)
|
# Should succeed via HasPermission check (not special case)
|
||||||
{:ok, updated_member} =
|
{:ok, updated_member} =
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,13 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
%{actor: system_actor}
|
||||||
|
end
|
||||||
|
|
||||||
# Helper to create a membership fee type
|
# Helper to create a membership fee type
|
||||||
defp create_fee_type(attrs) do
|
defp create_fee_type(attrs, actor) do
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||||
amount: Decimal.new("50.00"),
|
amount: Decimal.new("50.00"),
|
||||||
|
|
@ -31,12 +36,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||||
|
|
||||||
MembershipFeeType
|
MembershipFeeType
|
||||||
|> Ash.Changeset.for_create(:create, attrs)
|
|> Ash.Changeset.for_create(:create, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a member. Note: If membership_fee_type_id is provided,
|
# Helper to create a member. Note: If membership_fee_type_id is provided,
|
||||||
# cycles will be auto-generated during creation in test environment.
|
# cycles will be auto-generated during creation in test environment.
|
||||||
defp create_member(attrs) do
|
defp create_member(attrs, actor) do
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
first_name: "Test",
|
first_name: "Test",
|
||||||
last_name: "User",
|
last_name: "User",
|
||||||
|
|
@ -47,7 +52,7 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||||
|
|
||||||
Member
|
Member
|
||||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a member and explicitly generate cycles with a fixed "today" date.
|
# 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,
|
# 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
|
# which triggers the after_action hook. However, we then explicitly regenerate
|
||||||
# cycles with the fixed "today" date to ensure consistency.
|
# 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
|
# Extract membership_fee_type_id if present
|
||||||
fee_type_id = Map.get(attrs, :membership_fee_type_id)
|
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)
|
attrs_without_fee_type = Map.delete(attrs, :membership_fee_type_id)
|
||||||
|
|
||||||
member =
|
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)
|
# Assign fee type if provided (this will trigger auto-generation with real today)
|
||||||
member =
|
member =
|
||||||
if fee_type_id do
|
if fee_type_id do
|
||||||
member
|
member
|
||||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type_id})
|
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type_id})
|
||||||
|> Ash.update!()
|
|> Ash.update!(actor: actor)
|
||||||
else
|
else
|
||||||
member
|
member
|
||||||
end
|
end
|
||||||
|
|
@ -80,8 +85,8 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||||
# This ensures the test uses the fixed date, not the real current date
|
# This ensures the test uses the fixed date, not the real current date
|
||||||
if fee_type_id && member.join_date do
|
if fee_type_id && member.join_date do
|
||||||
# Delete any existing cycles first to ensure clean state
|
# Delete any existing cycles first to ensure clean state
|
||||||
existing_cycles = get_member_cycles(member.id)
|
existing_cycles = get_member_cycles(member.id, actor)
|
||||||
Enum.each(existing_cycles, &Ash.destroy!(&1))
|
Enum.each(existing_cycles, &Ash.destroy!(&1, actor: actor))
|
||||||
|
|
||||||
# Generate cycles with fixed "today" date
|
# Generate cycles with fixed "today" date
|
||||||
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
||||||
|
|
@ -91,85 +96,91 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to get cycles for a member
|
# Helper to get cycles for a member
|
||||||
defp get_member_cycles(member_id) do
|
defp get_member_cycles(member_id, actor) do
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member_id)
|
|> Ash.Query.filter(member_id == ^member_id)
|
||||||
|> Ash.Query.sort(cycle_start: :asc)
|
|> Ash.Query.sort(cycle_start: :asc)
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to set up settings
|
# 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()
|
{:ok, settings} = Mv.Membership.get_settings()
|
||||||
|
|
||||||
settings
|
settings
|
||||||
|> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle})
|
|> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle})
|
||||||
|> Ash.update!()
|
|> Ash.update!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "member joins today" do
|
describe "member joins today" do
|
||||||
test "current cycle is generated (yearly)" do
|
test "current cycle is generated (yearly)", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
|
||||||
today = ~D[2024-06-15]
|
today = ~D[2024-06-15]
|
||||||
|
|
||||||
# Create member WITHOUT fee type first to avoid auto-generation with real today
|
# Create member WITHOUT fee type first to avoid auto-generation with real today
|
||||||
member =
|
member =
|
||||||
create_member(%{
|
create_member(
|
||||||
join_date: today,
|
%{
|
||||||
membership_fee_start_date: ~D[2024-01-01]
|
join_date: today,
|
||||||
})
|
membership_fee_start_date: ~D[2024-01-01]
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
# Assign fee type
|
# Assign fee type
|
||||||
member =
|
member =
|
||||||
member
|
member
|
||||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
|> 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
|
# Explicitly generate cycles with fixed "today" date
|
||||||
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
||||||
|
|
||||||
# Check all cycles
|
# Check all cycles
|
||||||
cycles = get_member_cycles(member.id)
|
cycles = get_member_cycles(member.id, actor)
|
||||||
|
|
||||||
# Should have the current year's cycle
|
# Should have the current year's cycle
|
||||||
cycle_years = Enum.map(cycles, & &1.cycle_start.year)
|
cycle_years = Enum.map(cycles, & &1.cycle_start.year)
|
||||||
assert 2024 in cycle_years
|
assert 2024 in cycle_years
|
||||||
end
|
end
|
||||||
|
|
||||||
test "current cycle is generated (monthly)" do
|
test "current cycle is generated (monthly)", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
fee_type = create_fee_type(%{interval: :monthly})
|
fee_type = create_fee_type(%{interval: :monthly}, actor)
|
||||||
|
|
||||||
today = ~D[2024-06-15]
|
today = ~D[2024-06-15]
|
||||||
|
|
||||||
# Create member WITHOUT fee type first to avoid auto-generation with real today
|
# Create member WITHOUT fee type first to avoid auto-generation with real today
|
||||||
member =
|
member =
|
||||||
create_member(%{
|
create_member(
|
||||||
join_date: today,
|
%{
|
||||||
membership_fee_start_date: ~D[2024-06-01]
|
join_date: today,
|
||||||
})
|
membership_fee_start_date: ~D[2024-06-01]
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
# Assign fee type
|
# Assign fee type
|
||||||
member =
|
member =
|
||||||
member
|
member
|
||||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
|> 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
|
# Explicitly generate cycles with fixed "today" date
|
||||||
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
||||||
|
|
||||||
# Check all cycles
|
# Check all cycles
|
||||||
cycles = get_member_cycles(member.id)
|
cycles = get_member_cycles(member.id, actor)
|
||||||
|
|
||||||
# Should have June 2024 cycle
|
# Should have June 2024 cycle
|
||||||
assert Enum.any?(cycles, fn c -> c.cycle_start == ~D[2024-06-01] end)
|
assert Enum.any?(cycles, fn c -> c.cycle_start == ~D[2024-06-01] end)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "current cycle is generated (quarterly)" do
|
test "current cycle is generated (quarterly)", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
fee_type = create_fee_type(%{interval: :quarterly})
|
fee_type = create_fee_type(%{interval: :quarterly}, actor)
|
||||||
|
|
||||||
today = ~D[2024-05-15]
|
today = ~D[2024-05-15]
|
||||||
|
|
||||||
|
|
@ -181,11 +192,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||||
membership_fee_type_id: fee_type.id,
|
membership_fee_type_id: fee_type.id,
|
||||||
membership_fee_start_date: ~D[2024-04-01]
|
membership_fee_start_date: ~D[2024-04-01]
|
||||||
},
|
},
|
||||||
today
|
today,
|
||||||
|
actor
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check all cycles
|
# Check all cycles
|
||||||
cycles = get_member_cycles(member.id)
|
cycles = get_member_cycles(member.id, actor)
|
||||||
|
|
||||||
# Should have Q2 2024 cycle
|
# Should have Q2 2024 cycle
|
||||||
assert Enum.any?(cycles, fn c -> c.cycle_start == ~D[2024-04-01] end)
|
assert Enum.any?(cycles, fn c -> c.cycle_start == ~D[2024-04-01] end)
|
||||||
|
|
@ -193,9 +205,9 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "member left yesterday" do
|
describe "member left yesterday" do
|
||||||
test "no future cycles are generated" do
|
test "no future cycles are generated", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
|
||||||
today = ~D[2024-06-15]
|
today = ~D[2024-06-15]
|
||||||
yesterday = Date.add(today, -1)
|
yesterday = Date.add(today, -1)
|
||||||
|
|
@ -209,11 +221,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||||
membership_fee_type_id: fee_type.id,
|
membership_fee_type_id: fee_type.id,
|
||||||
membership_fee_start_date: ~D[2022-01-01]
|
membership_fee_start_date: ~D[2022-01-01]
|
||||||
},
|
},
|
||||||
today
|
today,
|
||||||
|
actor
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check all cycles
|
# 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()
|
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
||||||
|
|
||||||
# 2024 should be included because the member was still active during that cycle
|
# 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
|
refute 2025 in cycle_years
|
||||||
end
|
end
|
||||||
|
|
||||||
test "exit during first month of year stops at that year (monthly)" do
|
test "exit during first month of year stops at that year (monthly)", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
fee_type = create_fee_type(%{interval: :monthly})
|
fee_type = create_fee_type(%{interval: :monthly}, actor)
|
||||||
|
|
||||||
# Create member - cycles will be auto-generated
|
# Create member - cycles will be auto-generated
|
||||||
member =
|
member =
|
||||||
create_member(%{
|
create_member(
|
||||||
join_date: ~D[2024-01-15],
|
%{
|
||||||
exit_date: ~D[2024-03-15],
|
join_date: ~D[2024-01-15],
|
||||||
membership_fee_type_id: fee_type.id,
|
exit_date: ~D[2024-03-15],
|
||||||
membership_fee_start_date: ~D[2024-01-01]
|
membership_fee_type_id: fee_type.id,
|
||||||
})
|
membership_fee_start_date: ~D[2024-01-01]
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
# Check all cycles
|
# 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()
|
cycle_months = Enum.map(cycles, & &1.cycle_start.month) |> Enum.sort()
|
||||||
|
|
||||||
assert 1 in cycle_months
|
assert 1 in cycle_months
|
||||||
|
|
@ -253,18 +269,21 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "member has no cycles initially" do
|
describe "member has no cycles initially" do
|
||||||
test "returns error when fee type is not assigned" do
|
test "returns error when fee type is not assigned", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
|
|
||||||
# Create member WITHOUT fee type (no auto-generation)
|
# Create member WITHOUT fee type (no auto-generation)
|
||||||
member =
|
member =
|
||||||
create_member(%{
|
create_member(
|
||||||
join_date: ~D[2022-03-15],
|
%{
|
||||||
membership_fee_start_date: ~D[2022-01-01]
|
join_date: ~D[2022-03-15],
|
||||||
})
|
membership_fee_start_date: ~D[2022-01-01]
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
# Verify no cycles exist initially
|
# Verify no cycles exist initially
|
||||||
initial_cycles = get_member_cycles(member.id)
|
initial_cycles = get_member_cycles(member.id, actor)
|
||||||
assert initial_cycles == []
|
assert initial_cycles == []
|
||||||
|
|
||||||
# Trying to generate cycles without fee type should return error
|
# 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}
|
assert result == {:error, :no_membership_fee_type}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "generates all cycles when member is created with fee type" do
|
test "generates all cycles when member is created with fee type", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
|
||||||
today = ~D[2024-06-15]
|
today = ~D[2024-06-15]
|
||||||
|
|
||||||
|
|
@ -286,11 +305,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||||
membership_fee_type_id: fee_type.id,
|
membership_fee_type_id: fee_type.id,
|
||||||
membership_fee_start_date: ~D[2022-01-01]
|
membership_fee_start_date: ~D[2022-01-01]
|
||||||
},
|
},
|
||||||
today
|
today,
|
||||||
|
actor
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check all cycles
|
# 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)
|
# Should have generated all cycles from 2022 to 2024 (3 cycles)
|
||||||
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
||||||
|
|
@ -303,16 +323,19 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "member has existing cycles" do
|
describe "member has existing cycles" do
|
||||||
test "generates from last cycle (not duplicating existing)" do
|
test "generates from last cycle (not duplicating existing)", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
|
||||||
# Create member WITHOUT fee type first
|
# Create member WITHOUT fee type first
|
||||||
member =
|
member =
|
||||||
create_member(%{
|
create_member(
|
||||||
join_date: ~D[2022-03-15],
|
%{
|
||||||
membership_fee_start_date: ~D[2022-01-01]
|
join_date: ~D[2022-03-15],
|
||||||
})
|
membership_fee_start_date: ~D[2022-01-01]
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
# Manually create an existing cycle for 2022
|
# Manually create an existing cycle for 2022
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|
|
@ -323,20 +346,20 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||||
amount: fee_type.amount,
|
amount: fee_type.amount,
|
||||||
status: :paid
|
status: :paid
|
||||||
})
|
})
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
|
|
||||||
# Now assign fee type
|
# Now assign fee type
|
||||||
member =
|
member =
|
||||||
member
|
member
|
||||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
|> 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
|
# Explicitly generate cycles with fixed "today" date
|
||||||
today = ~D[2024-06-15]
|
today = ~D[2024-06-15]
|
||||||
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
||||||
|
|
||||||
# Check all cycles
|
# 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()
|
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)
|
# Should have 2022 (manually created), 2023 and 2024 (auto-generated)
|
||||||
|
|
@ -350,9 +373,9 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "year boundary handling" do
|
describe "year boundary handling" do
|
||||||
test "cycles span across year boundaries correctly (yearly)" do
|
test "cycles span across year boundaries correctly (yearly)", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
|
||||||
today = ~D[2024-06-15]
|
today = ~D[2024-06-15]
|
||||||
|
|
||||||
|
|
@ -364,11 +387,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||||
membership_fee_type_id: fee_type.id,
|
membership_fee_type_id: fee_type.id,
|
||||||
membership_fee_start_date: ~D[2023-01-01]
|
membership_fee_start_date: ~D[2023-01-01]
|
||||||
},
|
},
|
||||||
today
|
today,
|
||||||
|
actor
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check all cycles
|
# 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()
|
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
||||||
|
|
||||||
# Should have 2023 and 2024
|
# Should have 2023 and 2024
|
||||||
|
|
@ -376,9 +400,9 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||||
assert 2024 in cycle_years
|
assert 2024 in cycle_years
|
||||||
end
|
end
|
||||||
|
|
||||||
test "cycles span across year boundaries correctly (quarterly)" do
|
test "cycles span across year boundaries correctly (quarterly)", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
fee_type = create_fee_type(%{interval: :quarterly})
|
fee_type = create_fee_type(%{interval: :quarterly}, actor)
|
||||||
|
|
||||||
today = ~D[2024-12-15]
|
today = ~D[2024-12-15]
|
||||||
|
|
||||||
|
|
@ -390,20 +414,21 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||||
membership_fee_type_id: fee_type.id,
|
membership_fee_type_id: fee_type.id,
|
||||||
membership_fee_start_date: ~D[2024-10-01]
|
membership_fee_start_date: ~D[2024-10-01]
|
||||||
},
|
},
|
||||||
today
|
today,
|
||||||
|
actor
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check all cycles
|
# 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)
|
cycle_starts = Enum.map(cycles, & &1.cycle_start) |> Enum.sort(Date)
|
||||||
|
|
||||||
# Should have Q4 2024
|
# Should have Q4 2024
|
||||||
assert ~D[2024-10-01] in cycle_starts
|
assert ~D[2024-10-01] in cycle_starts
|
||||||
end
|
end
|
||||||
|
|
||||||
test "December to January transition (monthly)" do
|
test "December to January transition (monthly)", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
fee_type = create_fee_type(%{interval: :monthly})
|
fee_type = create_fee_type(%{interval: :monthly}, actor)
|
||||||
|
|
||||||
today = ~D[2024-12-31]
|
today = ~D[2024-12-31]
|
||||||
|
|
||||||
|
|
@ -415,11 +440,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||||
membership_fee_type_id: fee_type.id,
|
membership_fee_type_id: fee_type.id,
|
||||||
membership_fee_start_date: ~D[2024-12-01]
|
membership_fee_start_date: ~D[2024-12-01]
|
||||||
},
|
},
|
||||||
today
|
today,
|
||||||
|
actor
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check all cycles
|
# 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)
|
cycle_starts = Enum.map(cycles, & &1.cycle_start) |> Enum.sort(Date)
|
||||||
|
|
||||||
# Should have Dec 2024
|
# Should have Dec 2024
|
||||||
|
|
@ -428,9 +454,9 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "leap year handling" do
|
describe "leap year handling" do
|
||||||
test "February cycles in leap year" do
|
test "February cycles in leap year", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
fee_type = create_fee_type(%{interval: :monthly})
|
fee_type = create_fee_type(%{interval: :monthly}, actor)
|
||||||
|
|
||||||
today = ~D[2024-03-15]
|
today = ~D[2024-03-15]
|
||||||
|
|
||||||
|
|
@ -443,11 +469,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||||
membership_fee_type_id: fee_type.id,
|
membership_fee_type_id: fee_type.id,
|
||||||
membership_fee_start_date: ~D[2024-02-01]
|
membership_fee_start_date: ~D[2024-02-01]
|
||||||
},
|
},
|
||||||
today
|
today,
|
||||||
|
actor
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check all cycles
|
# Check all cycles
|
||||||
cycles = get_member_cycles(member.id)
|
cycles = get_member_cycles(member.id, actor)
|
||||||
|
|
||||||
# Should have February 2024 cycle
|
# Should have February 2024 cycle
|
||||||
feb_cycle = Enum.find(cycles, fn c -> c.cycle_start == ~D[2024-02-01] end)
|
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
|
assert feb_cycle != nil
|
||||||
end
|
end
|
||||||
|
|
||||||
test "February cycles in non-leap year" do
|
test "February cycles in non-leap year", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
fee_type = create_fee_type(%{interval: :monthly})
|
fee_type = create_fee_type(%{interval: :monthly}, actor)
|
||||||
|
|
||||||
today = ~D[2023-03-15]
|
today = ~D[2023-03-15]
|
||||||
|
|
||||||
|
|
@ -470,11 +497,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||||
membership_fee_type_id: fee_type.id,
|
membership_fee_type_id: fee_type.id,
|
||||||
membership_fee_start_date: ~D[2023-02-01]
|
membership_fee_start_date: ~D[2023-02-01]
|
||||||
},
|
},
|
||||||
today
|
today,
|
||||||
|
actor
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check all cycles
|
# Check all cycles
|
||||||
cycles = get_member_cycles(member.id)
|
cycles = get_member_cycles(member.id, actor)
|
||||||
|
|
||||||
# Should have February 2023 cycle
|
# Should have February 2023 cycle
|
||||||
feb_cycle = Enum.find(cycles, fn c -> c.cycle_start == ~D[2023-02-01] end)
|
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
|
assert feb_cycle != nil
|
||||||
end
|
end
|
||||||
|
|
||||||
test "yearly cycle in leap year" do
|
test "yearly cycle in leap year", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
|
||||||
today = ~D[2024-12-31]
|
today = ~D[2024-12-31]
|
||||||
|
|
||||||
|
|
@ -496,11 +524,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||||
membership_fee_type_id: fee_type.id,
|
membership_fee_type_id: fee_type.id,
|
||||||
membership_fee_start_date: ~D[2024-01-01]
|
membership_fee_start_date: ~D[2024-01-01]
|
||||||
},
|
},
|
||||||
today
|
today,
|
||||||
|
actor
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check all cycles
|
# Check all cycles
|
||||||
cycles = get_member_cycles(member.id)
|
cycles = get_member_cycles(member.id, actor)
|
||||||
|
|
||||||
# Should have 2024 cycle
|
# Should have 2024 cycle
|
||||||
cycle_2024 = Enum.find(cycles, fn c -> c.cycle_start == ~D[2024-01-01] end)
|
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
|
end
|
||||||
|
|
||||||
describe "include_joining_cycle variations" do
|
describe "include_joining_cycle variations" do
|
||||||
test "include_joining_cycle = true starts from joining cycle" do
|
test "include_joining_cycle = true starts from joining cycle", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
|
||||||
today = ~D[2024-06-15]
|
today = ~D[2024-06-15]
|
||||||
|
|
||||||
|
|
@ -525,20 +554,21 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||||
membership_fee_type_id: fee_type.id
|
membership_fee_type_id: fee_type.id
|
||||||
# membership_fee_start_date will be auto-calculated
|
# membership_fee_start_date will be auto-calculated
|
||||||
},
|
},
|
||||||
today
|
today,
|
||||||
|
actor
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check all cycles
|
# 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()
|
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
||||||
|
|
||||||
# Should include 2023 (joining year)
|
# Should include 2023 (joining year)
|
||||||
assert 2023 in cycle_years
|
assert 2023 in cycle_years
|
||||||
end
|
end
|
||||||
|
|
||||||
test "include_joining_cycle = false starts from next cycle" do
|
test "include_joining_cycle = false starts from next cycle", %{actor: actor} do
|
||||||
setup_settings(false)
|
setup_settings(false, actor)
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
|
||||||
today = ~D[2024-06-15]
|
today = ~D[2024-06-15]
|
||||||
|
|
||||||
|
|
@ -551,11 +581,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||||
membership_fee_type_id: fee_type.id
|
membership_fee_type_id: fee_type.id
|
||||||
# membership_fee_start_date will be auto-calculated
|
# membership_fee_start_date will be auto-calculated
|
||||||
},
|
},
|
||||||
today
|
today,
|
||||||
|
actor
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check all cycles
|
# 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()
|
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
||||||
|
|
||||||
# Should NOT include 2023 (joining year)
|
# Should NOT include 2023 (joining year)
|
||||||
|
|
@ -567,17 +598,22 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "inactive member processing" do
|
describe "inactive member processing" do
|
||||||
test "inactive members receive cycles up to exit_date via generate_cycles_for_all_members" do
|
test "inactive members receive cycles up to exit_date via generate_cycles_for_all_members", %{
|
||||||
setup_settings(true)
|
actor: actor
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
} do
|
||||||
|
setup_settings(true, actor)
|
||||||
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
|
||||||
# Create an inactive member (left in 2023) WITHOUT fee type initially
|
# Create an inactive member (left in 2023) WITHOUT fee type initially
|
||||||
# This simulates a member that was created before the fee system existed
|
# This simulates a member that was created before the fee system existed
|
||||||
member =
|
member =
|
||||||
create_member(%{
|
create_member(
|
||||||
join_date: ~D[2021-03-15],
|
%{
|
||||||
exit_date: ~D[2023-06-15]
|
join_date: ~D[2021-03-15],
|
||||||
})
|
exit_date: ~D[2023-06-15]
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
# Now assign fee type (simulating a retroactive assignment)
|
# Now assign fee type (simulating a retroactive assignment)
|
||||||
member =
|
member =
|
||||||
|
|
@ -586,7 +622,7 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||||
membership_fee_type_id: fee_type.id,
|
membership_fee_type_id: fee_type.id,
|
||||||
membership_fee_start_date: ~D[2021-01-01]
|
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
|
# Run batch generation with a "today" date after the member left
|
||||||
today = ~D[2024-06-15]
|
today = ~D[2024-06-15]
|
||||||
|
|
@ -596,7 +632,7 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||||
assert results.total >= 1
|
assert results.total >= 1
|
||||||
|
|
||||||
# Check the member's cycles
|
# 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()
|
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort() |> Enum.uniq()
|
||||||
|
|
||||||
# Should have 2021, 2022, 2023 (exit year included)
|
# Should have 2021, 2022, 2023 (exit year included)
|
||||||
|
|
@ -608,9 +644,9 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||||
refute 2024 in cycle_years
|
refute 2024 in cycle_years
|
||||||
end
|
end
|
||||||
|
|
||||||
test "exit_date on cycle_start still generates that cycle" do
|
test "exit_date on cycle_start still generates that cycle", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
|
||||||
today = ~D[2024-12-31]
|
today = ~D[2024-12-31]
|
||||||
|
|
||||||
|
|
@ -624,11 +660,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||||
membership_fee_type_id: fee_type.id,
|
membership_fee_type_id: fee_type.id,
|
||||||
membership_fee_start_date: ~D[2022-01-01]
|
membership_fee_start_date: ~D[2022-01-01]
|
||||||
},
|
},
|
||||||
today
|
today,
|
||||||
|
actor
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check cycles
|
# 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()
|
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
||||||
|
|
||||||
# 2024 should be included because exit_date == cycle_start means
|
# 2024 should be included because exit_date == cycle_start means
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,13 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
%{actor: system_actor}
|
||||||
|
end
|
||||||
|
|
||||||
# Helper to create a membership fee type
|
# Helper to create a membership fee type
|
||||||
defp create_fee_type(attrs) do
|
defp create_fee_type(attrs, actor) do
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||||
amount: Decimal.new("50.00"),
|
amount: Decimal.new("50.00"),
|
||||||
|
|
@ -23,11 +28,11 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
||||||
|
|
||||||
MembershipFeeType
|
MembershipFeeType
|
||||||
|> Ash.Changeset.for_create(:create, attrs)
|
|> Ash.Changeset.for_create(:create, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a member without triggering cycle generation
|
# 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 = %{
|
default_attrs = %{
|
||||||
first_name: "Test",
|
first_name: "Test",
|
||||||
last_name: "User",
|
last_name: "User",
|
||||||
|
|
@ -38,50 +43,53 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
||||||
|
|
||||||
Member
|
Member
|
||||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to set up settings with specific include_joining_cycle value
|
# 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()
|
{:ok, settings} = Mv.Membership.get_settings()
|
||||||
|
|
||||||
settings
|
settings
|
||||||
|> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle})
|
|> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle})
|
||||||
|> Ash.update!()
|
|> Ash.update!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to get cycles for a member
|
# Helper to get cycles for a member
|
||||||
defp get_member_cycles(member_id) do
|
defp get_member_cycles(member_id, actor) do
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member_id)
|
|> Ash.Query.filter(member_id == ^member_id)
|
||||||
|> Ash.Query.sort(cycle_start: :asc)
|
|> Ash.Query.sort(cycle_start: :asc)
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "generate_cycles_for_member/2" do
|
describe "generate_cycles_for_member/2" do
|
||||||
test "generates cycles from start date to today" do
|
test "generates cycles from start date to today", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
|
||||||
# Create member WITHOUT fee type first to avoid auto-generation
|
# Create member WITHOUT fee type first to avoid auto-generation
|
||||||
member =
|
member =
|
||||||
create_member_without_cycles(%{
|
create_member_without_cycles(
|
||||||
join_date: ~D[2022-03-15],
|
%{
|
||||||
membership_fee_start_date: ~D[2022-01-01]
|
join_date: ~D[2022-03-15],
|
||||||
})
|
membership_fee_start_date: ~D[2022-01-01]
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
# Assign fee type
|
# Assign fee type
|
||||||
member =
|
member =
|
||||||
member
|
member
|
||||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
|> 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
|
# Explicitly generate cycles with fixed "today" date to avoid date dependency
|
||||||
today = ~D[2024-06-15]
|
today = ~D[2024-06-15]
|
||||||
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
||||||
|
|
||||||
# Verify cycles were generated
|
# 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()
|
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,
|
# 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
|
assert 2024 in cycle_years
|
||||||
end
|
end
|
||||||
|
|
||||||
test "generates cycles from last existing cycle" do
|
test "generates cycles from last existing cycle", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
|
||||||
# Create member without fee type first to avoid auto-generation
|
# Create member without fee type first to avoid auto-generation
|
||||||
member =
|
member =
|
||||||
create_member_without_cycles(%{
|
create_member_without_cycles(
|
||||||
join_date: ~D[2022-03-15],
|
%{
|
||||||
membership_fee_start_date: ~D[2022-01-01]
|
join_date: ~D[2022-03-15],
|
||||||
})
|
membership_fee_start_date: ~D[2022-01-01]
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
# Manually create a cycle for 2022
|
# Manually create a cycle for 2022
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|
|
@ -112,13 +123,13 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
||||||
amount: fee_type.amount,
|
amount: fee_type.amount,
|
||||||
status: :paid
|
status: :paid
|
||||||
})
|
})
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
|
|
||||||
# Now assign fee type to member
|
# Now assign fee type to member
|
||||||
member =
|
member =
|
||||||
member
|
member
|
||||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
|> 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
|
# Generate cycles with specific "today" date
|
||||||
today = ~D[2024-06-15]
|
today = ~D[2024-06-15]
|
||||||
|
|
@ -130,17 +141,20 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
||||||
assert 2022 not in new_cycle_years
|
assert 2022 not in new_cycle_years
|
||||||
end
|
end
|
||||||
|
|
||||||
test "respects left_at boundary (stops generation)" do
|
test "respects left_at boundary (stops generation)", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
|
||||||
member =
|
member =
|
||||||
create_member_without_cycles(%{
|
create_member_without_cycles(
|
||||||
join_date: ~D[2022-03-15],
|
%{
|
||||||
exit_date: ~D[2023-06-15],
|
join_date: ~D[2022-03-15],
|
||||||
membership_fee_type_id: fee_type.id,
|
exit_date: ~D[2023-06-15],
|
||||||
membership_fee_start_date: ~D[2022-01-01]
|
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
|
# Generate cycles with specific "today" date far in the future
|
||||||
today = ~D[2025-06-15]
|
today = ~D[2025-06-15]
|
||||||
|
|
@ -154,16 +168,19 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
||||||
assert 2025 not in cycle_years
|
assert 2025 not in cycle_years
|
||||||
end
|
end
|
||||||
|
|
||||||
test "skips existing cycles (idempotent)" do
|
test "skips existing cycles (idempotent)", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
|
||||||
member =
|
member =
|
||||||
create_member_without_cycles(%{
|
create_member_without_cycles(
|
||||||
join_date: ~D[2023-03-15],
|
%{
|
||||||
membership_fee_type_id: fee_type.id,
|
join_date: ~D[2023-03-15],
|
||||||
membership_fee_start_date: ~D[2023-01-01]
|
membership_fee_type_id: fee_type.id,
|
||||||
})
|
membership_fee_start_date: ~D[2023-01-01]
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
today = ~D[2024-06-15]
|
today = ~D[2024-06-15]
|
||||||
|
|
||||||
|
|
@ -177,37 +194,43 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
||||||
assert second_cycles == []
|
assert second_cycles == []
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does not fill gaps when cycles were deleted" do
|
test "does not fill gaps when cycles were deleted", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
|
||||||
# Create member without fee type first to control which cycles exist
|
# Create member without fee type first to control which cycles exist
|
||||||
member =
|
member =
|
||||||
create_member_without_cycles(%{
|
create_member_without_cycles(
|
||||||
join_date: ~D[2020-03-15],
|
%{
|
||||||
membership_fee_start_date: ~D[2020-01-01]
|
join_date: ~D[2020-03-15],
|
||||||
})
|
membership_fee_start_date: ~D[2020-01-01]
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
# Manually create cycles for 2020, 2021, 2022, 2023
|
# Manually create cycles for 2020, 2021, 2022, 2023
|
||||||
for year <- [2020, 2021, 2022, 2023] do
|
for year <- [2020, 2021, 2022, 2023] do
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(
|
||||||
cycle_start: Date.new!(year, 1, 1),
|
:create,
|
||||||
member_id: member.id,
|
%{
|
||||||
membership_fee_type_id: fee_type.id,
|
cycle_start: Date.new!(year, 1, 1),
|
||||||
amount: fee_type.amount,
|
member_id: member.id,
|
||||||
status: :unpaid
|
membership_fee_type_id: fee_type.id,
|
||||||
})
|
amount: fee_type.amount,
|
||||||
|> Ash.create!()
|
status: :unpaid
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|> Ash.create!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Delete the 2021 cycle (create a gap)
|
# Delete the 2021 cycle (create a gap)
|
||||||
cycle_2021 =
|
cycle_2021 =
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^~D[2021-01-01])
|
|> 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)
|
# Now assign fee type to member (this triggers generation)
|
||||||
# Since cycles already exist (2020, 2022, 2023), the generator will
|
# Since cycles already exist (2020, 2022, 2023), the generator will
|
||||||
|
|
@ -215,10 +238,10 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
||||||
member =
|
member =
|
||||||
member
|
member
|
||||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
|> 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
|
# 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()
|
all_cycle_years = Enum.map(all_cycles, & &1.cycle_start.year) |> Enum.sort()
|
||||||
|
|
||||||
# 2021 should NOT exist (gap was not filled)
|
# 2021 should NOT exist (gap was not filled)
|
||||||
|
|
@ -234,20 +257,23 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
||||||
assert 2025 in all_cycle_years
|
assert 2025 in all_cycle_years
|
||||||
end
|
end
|
||||||
|
|
||||||
test "sets correct amount from membership fee type" do
|
test "sets correct amount from membership fee type", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
amount = Decimal.new("75.50")
|
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 =
|
member =
|
||||||
create_member_without_cycles(%{
|
create_member_without_cycles(
|
||||||
join_date: ~D[2024-03-15],
|
%{
|
||||||
membership_fee_type_id: fee_type.id,
|
join_date: ~D[2024-03-15],
|
||||||
membership_fee_start_date: ~D[2024-01-01]
|
membership_fee_type_id: fee_type.id,
|
||||||
})
|
membership_fee_start_date: ~D[2024-01-01]
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
# Verify cycles were generated with correct amount
|
# 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"
|
refute Enum.empty?(all_cycles), "Expected cycles to be generated"
|
||||||
|
|
||||||
# All cycles should have the correct amount
|
# All cycles should have the correct amount
|
||||||
|
|
@ -256,21 +282,24 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles NULL membership_fee_start_date by calculating from join_date" do
|
test "handles NULL membership_fee_start_date by calculating from join_date", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
fee_type = create_fee_type(%{interval: :quarterly})
|
fee_type = create_fee_type(%{interval: :quarterly}, actor)
|
||||||
|
|
||||||
# Create member without membership_fee_start_date - it will be auto-calculated
|
# Create member without membership_fee_start_date - it will be auto-calculated
|
||||||
# and cycles will be auto-generated
|
# and cycles will be auto-generated
|
||||||
member =
|
member =
|
||||||
create_member_without_cycles(%{
|
create_member_without_cycles(
|
||||||
join_date: ~D[2024-02-15],
|
%{
|
||||||
membership_fee_type_id: fee_type.id
|
join_date: ~D[2024-02-15],
|
||||||
# No membership_fee_start_date - should be calculated
|
membership_fee_type_id: fee_type.id
|
||||||
})
|
# No membership_fee_start_date - should be calculated
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
# Verify cycles were auto-generated
|
# 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),
|
# With include_joining_cycle=true and join_date=2024-02-15 (quarterly),
|
||||||
# start_date should be 2024-01-01 (Q1 start)
|
# 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]
|
assert first_cycle_start == ~D[2024-01-01]
|
||||||
end
|
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
|
# Create member without fee type - no auto-generation will occur
|
||||||
member =
|
member =
|
||||||
create_member_without_cycles(%{
|
create_member_without_cycles(
|
||||||
join_date: ~D[2024-03-15]
|
%{
|
||||||
# No membership_fee_type_id
|
join_date: ~D[2024-03-15]
|
||||||
})
|
# No membership_fee_type_id
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
{:error, reason} = CycleGenerator.generate_cycles_for_member(member.id)
|
{:error, reason} = CycleGenerator.generate_cycles_for_member(member.id)
|
||||||
assert reason == :no_membership_fee_type
|
assert reason == :no_membership_fee_type
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns error when member has no join_date" do
|
test "returns error when member has no join_date", %{actor: actor} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
|
||||||
# Create member without join_date - no auto-generation will occur
|
# Create member without join_date - no auto-generation will occur
|
||||||
# (after_action hook checks for join_date)
|
# (after_action hook checks for join_date)
|
||||||
member =
|
member =
|
||||||
create_member_without_cycles(%{
|
create_member_without_cycles(
|
||||||
membership_fee_type_id: fee_type.id
|
%{
|
||||||
# No join_date
|
membership_fee_type_id: fee_type.id
|
||||||
})
|
# No join_date
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
{:error, reason} = CycleGenerator.generate_cycles_for_member(member.id)
|
{:error, reason} = CycleGenerator.generate_cycles_for_member(member.id)
|
||||||
assert reason == :no_join_date
|
assert reason == :no_join_date
|
||||||
|
|
@ -357,24 +392,30 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "generate_cycles_for_all_members/1" do
|
describe "generate_cycles_for_all_members/1" do
|
||||||
test "generates cycles for multiple members" do
|
test "generates cycles for multiple members", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
|
||||||
# Create multiple members
|
# Create multiple members
|
||||||
_member1 =
|
_member1 =
|
||||||
create_member_without_cycles(%{
|
create_member_without_cycles(
|
||||||
join_date: ~D[2024-01-15],
|
%{
|
||||||
membership_fee_type_id: fee_type.id,
|
join_date: ~D[2024-01-15],
|
||||||
membership_fee_start_date: ~D[2024-01-01]
|
membership_fee_type_id: fee_type.id,
|
||||||
})
|
membership_fee_start_date: ~D[2024-01-01]
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
_member2 =
|
_member2 =
|
||||||
create_member_without_cycles(%{
|
create_member_without_cycles(
|
||||||
join_date: ~D[2024-02-15],
|
%{
|
||||||
membership_fee_type_id: fee_type.id,
|
join_date: ~D[2024-02-15],
|
||||||
membership_fee_start_date: ~D[2024-01-01]
|
membership_fee_type_id: fee_type.id,
|
||||||
})
|
membership_fee_start_date: ~D[2024-01-01]
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
today = ~D[2024-06-15]
|
today = ~D[2024-06-15]
|
||||||
{:ok, results} = CycleGenerator.generate_cycles_for_all_members(today: today)
|
{:ok, results} = CycleGenerator.generate_cycles_for_all_members(today: today)
|
||||||
|
|
@ -387,16 +428,19 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "lock mechanism" do
|
describe "lock mechanism" do
|
||||||
test "prevents concurrent generation for same member" do
|
test "prevents concurrent generation for same member", %{actor: actor} do
|
||||||
setup_settings(true)
|
setup_settings(true, actor)
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
|
||||||
member =
|
member =
|
||||||
create_member_without_cycles(%{
|
create_member_without_cycles(
|
||||||
join_date: ~D[2022-03-15],
|
%{
|
||||||
membership_fee_type_id: fee_type.id,
|
join_date: ~D[2022-03-15],
|
||||||
membership_fee_start_date: ~D[2022-01-01]
|
membership_fee_type_id: fee_type.id,
|
||||||
})
|
membership_fee_start_date: ~D[2022-01-01]
|
||||||
|
},
|
||||||
|
actor
|
||||||
|
)
|
||||||
|
|
||||||
today = ~D[2024-06-15]
|
today = ~D[2024-06-15]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,13 @@ defmodule MvWeb.OidcE2EFlowTest do
|
||||||
use MvWeb.ConnCase, async: true
|
use MvWeb.ConnCase, async: true
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
%{actor: system_actor}
|
||||||
|
end
|
||||||
|
|
||||||
describe "E2E: New OIDC user registration" do
|
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
|
# Simulate OIDC callback for brand new user
|
||||||
user_info = %{
|
user_info = %{
|
||||||
"sub" => "new_oidc_user_123",
|
"sub" => "new_oidc_user_123",
|
||||||
|
|
@ -18,10 +23,13 @@ defmodule MvWeb.OidcE2EFlowTest do
|
||||||
|
|
||||||
# Call register action
|
# Call register action
|
||||||
result =
|
result =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
assert {:ok, new_user} = result
|
assert {:ok, new_user} = result
|
||||||
assert to_string(new_user.email) == "newuser@example.com"
|
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
|
# Verify user can be found by oidc_id
|
||||||
{:ok, [found_user]} =
|
{:ok, [found_user]} =
|
||||||
Mv.Accounts.read_sign_in_with_rauthy(%{
|
Mv.Accounts.read_sign_in_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
assert found_user.id == new_user.id
|
assert found_user.id == new_user.id
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "E2E: Existing OIDC user sign-in" do
|
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
|
# Create OIDC user
|
||||||
user =
|
user =
|
||||||
create_test_user(%{
|
create_test_user(%{
|
||||||
|
|
@ -56,10 +67,13 @@ defmodule MvWeb.OidcE2EFlowTest do
|
||||||
|
|
||||||
# Register (upsert) with new email
|
# Register (upsert) with new email
|
||||||
{:ok, updated_user} =
|
{:ok, updated_user} =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: updated_user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: updated_user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Same user, updated email
|
# Same user, updated email
|
||||||
assert updated_user.id == user.id
|
assert updated_user.id == user.id
|
||||||
|
|
@ -70,7 +84,7 @@ defmodule MvWeb.OidcE2EFlowTest do
|
||||||
|
|
||||||
describe "E2E: OIDC with existing password account (Email Collision)" do
|
describe "E2E: OIDC with existing password account (Email Collision)" do
|
||||||
test "OIDC registration with password account email triggers PasswordVerificationRequired",
|
test "OIDC registration with password account email triggers PasswordVerificationRequired",
|
||||||
%{conn: _conn} do
|
%{conn: _conn, actor: actor} do
|
||||||
# Step 1: Create a password-only user
|
# Step 1: Create a password-only user
|
||||||
password_user =
|
password_user =
|
||||||
create_test_user(%{
|
create_test_user(%{
|
||||||
|
|
@ -86,10 +100,13 @@ defmodule MvWeb.OidcE2EFlowTest do
|
||||||
}
|
}
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Step 3: Should fail with PasswordVerificationRequired
|
# Step 3: Should fail with PasswordVerificationRequired
|
||||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
||||||
|
|
@ -106,7 +123,7 @@ defmodule MvWeb.OidcE2EFlowTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "full E2E flow: OIDC collision -> password verification -> account linked",
|
test "full E2E flow: OIDC collision -> password verification -> account linked",
|
||||||
%{conn: _conn} do
|
%{conn: _conn, actor: actor} do
|
||||||
# Step 1: Create password user
|
# Step 1: Create password user
|
||||||
password_user =
|
password_user =
|
||||||
create_test_user(%{
|
create_test_user(%{
|
||||||
|
|
@ -122,10 +139,13 @@ defmodule MvWeb.OidcE2EFlowTest do
|
||||||
}
|
}
|
||||||
|
|
||||||
{:error, %Ash.Error.Invalid{errors: errors}} =
|
{:error, %Ash.Error.Invalid{errors: errors}} =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Extract the error
|
# Extract the error
|
||||||
password_error =
|
password_error =
|
||||||
|
|
@ -142,12 +162,12 @@ defmodule MvWeb.OidcE2EFlowTest do
|
||||||
{:ok, linked_user} =
|
{:ok, linked_user} =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|> Ash.Query.filter(id == ^password_user.id)
|
|> Ash.Query.filter(id == ^password_user.id)
|
||||||
|> Ash.read_one!()
|
|> Ash.read_one!(actor: actor)
|
||||||
|> Ash.Changeset.for_update(:link_oidc_id, %{
|
|> Ash.Changeset.for_update(:link_oidc_id, %{
|
||||||
oidc_id: user_info["sub"],
|
oidc_id: user_info["sub"],
|
||||||
oidc_user_info: user_info
|
oidc_user_info: user_info
|
||||||
})
|
})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
# Verify account is now linked
|
# Verify account is now linked
|
||||||
assert linked_user.id == password_user.id
|
assert linked_user.id == password_user.id
|
||||||
|
|
@ -158,17 +178,20 @@ defmodule MvWeb.OidcE2EFlowTest do
|
||||||
|
|
||||||
# Step 5: User can now sign in via OIDC
|
# Step 5: User can now sign in via OIDC
|
||||||
{:ok, [signed_in_user]} =
|
{:ok, [signed_in_user]} =
|
||||||
Mv.Accounts.read_sign_in_with_rauthy(%{
|
Mv.Accounts.read_sign_in_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
assert signed_in_user.id == password_user.id
|
assert signed_in_user.id == password_user.id
|
||||||
assert signed_in_user.oidc_id == "oidc_link_888"
|
assert signed_in_user.oidc_id == "oidc_link_888"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "E2E: OIDC collision with different email at provider updates email after linking",
|
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 with old email
|
||||||
password_user =
|
password_user =
|
||||||
create_test_user(%{
|
create_test_user(%{
|
||||||
|
|
@ -199,12 +222,12 @@ defmodule MvWeb.OidcE2EFlowTest do
|
||||||
{:ok, linked_user} =
|
{:ok, linked_user} =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|> Ash.Query.filter(id == ^password_user.id)
|
|> Ash.Query.filter(id == ^password_user.id)
|
||||||
|> Ash.read_one!()
|
|> Ash.read_one!(actor: actor)
|
||||||
|> Ash.Changeset.for_update(:link_oidc_id, %{
|
|> Ash.Changeset.for_update(:link_oidc_id, %{
|
||||||
oidc_id: updated_user_info["sub"],
|
oidc_id: updated_user_info["sub"],
|
||||||
oidc_user_info: updated_user_info
|
oidc_user_info: updated_user_info
|
||||||
})
|
})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
# Email should be updated to match OIDC provider
|
# Email should be updated to match OIDC provider
|
||||||
assert to_string(linked_user.email) == "new@example.com"
|
assert to_string(linked_user.email) == "new@example.com"
|
||||||
|
|
@ -213,7 +236,10 @@ defmodule MvWeb.OidcE2EFlowTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "E2E: OIDC with linked member" do
|
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
|
# Create member
|
||||||
member =
|
member =
|
||||||
Ash.Seed.seed!(Mv.Membership.Member, %{
|
Ash.Seed.seed!(Mv.Membership.Member, %{
|
||||||
|
|
@ -239,10 +265,13 @@ defmodule MvWeb.OidcE2EFlowTest do
|
||||||
|
|
||||||
# Collision detected
|
# Collision detected
|
||||||
{:error, %Ash.Error.Invalid{}} =
|
{:error, %Ash.Error.Invalid{}} =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# After password verification, link OIDC with NEW email
|
# After password verification, link OIDC with NEW email
|
||||||
updated_user_info = %{
|
updated_user_info = %{
|
||||||
|
|
@ -253,24 +282,27 @@ defmodule MvWeb.OidcE2EFlowTest do
|
||||||
{:ok, linked_user} =
|
{:ok, linked_user} =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|> Ash.Query.filter(id == ^password_user.id)
|
|> Ash.Query.filter(id == ^password_user.id)
|
||||||
|> Ash.read_one!()
|
|> Ash.read_one!(actor: actor)
|
||||||
|> Ash.Changeset.for_update(:link_oidc_id, %{
|
|> Ash.Changeset.for_update(:link_oidc_id, %{
|
||||||
oidc_id: updated_user_info["sub"],
|
oidc_id: updated_user_info["sub"],
|
||||||
oidc_user_info: updated_user_info
|
oidc_user_info: updated_user_info
|
||||||
})
|
})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
# User email updated
|
# User email updated
|
||||||
assert to_string(linked_user.email) == "newmember@example.com"
|
assert to_string(linked_user.email) == "newmember@example.com"
|
||||||
|
|
||||||
# Member email should be synced
|
# 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"
|
assert to_string(updated_member.email) == "newmember@example.com"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "E2E: Security scenarios" do
|
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
|
# Create password user
|
||||||
_password_user =
|
_password_user =
|
||||||
create_test_user(%{
|
create_test_user(%{
|
||||||
|
|
@ -287,10 +319,13 @@ defmodule MvWeb.OidcE2EFlowTest do
|
||||||
|
|
||||||
# Sign-in should fail (no matching oidc_id)
|
# Sign-in should fail (no matching oidc_id)
|
||||||
result =
|
result =
|
||||||
Mv.Accounts.read_sign_in_with_rauthy(%{
|
Mv.Accounts.read_sign_in_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
case result do
|
case result do
|
||||||
{:ok, []} ->
|
{:ok, []} ->
|
||||||
|
|
@ -305,17 +340,23 @@ defmodule MvWeb.OidcE2EFlowTest do
|
||||||
|
|
||||||
# Registration should trigger password requirement
|
# Registration should trigger password requirement
|
||||||
{:error, %Ash.Error.Invalid{errors: errors}} =
|
{:error, %Ash.Error.Invalid{errors: errors}} =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
assert Enum.any?(errors, fn err ->
|
assert Enum.any?(errors, fn err ->
|
||||||
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
|
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
|
||||||
end)
|
end)
|
||||||
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 linked to OIDC provider A
|
||||||
_user =
|
_user =
|
||||||
create_test_user(%{
|
create_test_user(%{
|
||||||
|
|
@ -331,10 +372,13 @@ defmodule MvWeb.OidcE2EFlowTest do
|
||||||
|
|
||||||
# Should trigger hard error (not PasswordVerificationRequired)
|
# Should trigger hard error (not PasswordVerificationRequired)
|
||||||
{:error, %Ash.Error.Invalid{errors: errors}} =
|
{:error, %Ash.Error.Invalid{errors: errors}} =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Should have hard error about "already linked to a different OIDC account"
|
# Should have hard error about "already linked to a different OIDC account"
|
||||||
assert Enum.any?(errors, fn
|
assert Enum.any?(errors, fn
|
||||||
|
|
@ -351,7 +395,10 @@ defmodule MvWeb.OidcE2EFlowTest do
|
||||||
end)
|
end)
|
||||||
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
|
# User with empty oidc_id
|
||||||
_password_user =
|
_password_user =
|
||||||
create_test_user(%{
|
create_test_user(%{
|
||||||
|
|
@ -367,10 +414,13 @@ defmodule MvWeb.OidcE2EFlowTest do
|
||||||
}
|
}
|
||||||
|
|
||||||
{:error, %Ash.Error.Invalid{errors: errors}} =
|
{:error, %Ash.Error.Invalid{errors: errors}} =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Should require password (empty string = no OIDC)
|
# Should require password (empty string = no OIDC)
|
||||||
assert Enum.any?(errors, fn err ->
|
assert Enum.any?(errors, fn err ->
|
||||||
|
|
@ -380,32 +430,38 @@ defmodule MvWeb.OidcE2EFlowTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "E2E: Error scenarios" do
|
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 = %{
|
user_info = %{
|
||||||
"preferred_username" => "noid@example.com"
|
"preferred_username" => "noid@example.com"
|
||||||
}
|
}
|
||||||
|
|
||||||
{:error, %Ash.Error.Invalid{errors: errors}} =
|
{:error, %Ash.Error.Invalid{errors: errors}} =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
assert Enum.any?(errors, fn err ->
|
assert Enum.any?(errors, fn err ->
|
||||||
match?(%Ash.Error.Changes.InvalidChanges{}, err)
|
match?(%Ash.Error.Changes.InvalidChanges{}, err)
|
||||||
end)
|
end)
|
||||||
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 = %{
|
user_info = %{
|
||||||
"sub" => "noemail_123"
|
"sub" => "noemail_123"
|
||||||
}
|
}
|
||||||
|
|
||||||
{:error, %Ash.Error.Invalid{errors: errors}} =
|
{:error, %Ash.Error.Invalid{errors: errors}} =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
assert Enum.any?(errors, fn err ->
|
assert Enum.any?(errors, fn err ->
|
||||||
match?(%Ash.Error.Changes.Required{field: :email}, err)
|
match?(%Ash.Error.Changes.Required{field: :email}, err)
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,13 @@ defmodule MvWeb.OidcEmailUpdateTest do
|
||||||
"""
|
"""
|
||||||
use MvWeb.ConnCase, async: true
|
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
|
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
|
# Create OIDC user
|
||||||
{:ok, oidc_user} =
|
{:ok, oidc_user} =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|
|
@ -14,7 +19,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
|
||||||
email: "original@example.com"
|
email: "original@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_123")
|
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_123")
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# User logs in via OIDC with NEW email
|
# User logs in via OIDC with NEW email
|
||||||
user_info = %{
|
user_info = %{
|
||||||
|
|
@ -23,10 +28,13 @@ defmodule MvWeb.OidcEmailUpdateTest do
|
||||||
}
|
}
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{"access_token" => "test_token"}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{"access_token" => "test_token"}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Should succeed and email should be updated
|
# Should succeed and email should be updated
|
||||||
assert {:ok, updated_user} = result
|
assert {:ok, updated_user} = result
|
||||||
|
|
@ -37,7 +45,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "OIDC user updates email to email of passwordless user" do
|
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
|
# Create OIDC user
|
||||||
{:ok, _oidc_user} =
|
{:ok, _oidc_user} =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|
|
@ -45,7 +53,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
|
||||||
email: "oidcuser@example.com"
|
email: "oidcuser@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_456")
|
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_456")
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# Create passwordless user with target email
|
# Create passwordless user with target email
|
||||||
{:ok, _passwordless_user} =
|
{:ok, _passwordless_user} =
|
||||||
|
|
@ -53,7 +61,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
|
||||||
|> Ash.Changeset.for_create(:create_user, %{
|
|> Ash.Changeset.for_create(:create_user, %{
|
||||||
email: "taken@example.com"
|
email: "taken@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# OIDC user tries to update email to taken email
|
# OIDC user tries to update email to taken email
|
||||||
user_info = %{
|
user_info = %{
|
||||||
|
|
@ -62,10 +70,13 @@ defmodule MvWeb.OidcEmailUpdateTest do
|
||||||
}
|
}
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{"access_token" => "test_token"}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{"access_token" => "test_token"}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Should fail with email update conflict error
|
# Should fail with email update conflict error
|
||||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
||||||
|
|
@ -88,7 +99,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "OIDC user updates email to email of password-protected user" do
|
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
|
# Create OIDC user
|
||||||
{:ok, _oidc_user} =
|
{:ok, _oidc_user} =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|
|
@ -96,7 +107,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
|
||||||
email: "oidcuser2@example.com"
|
email: "oidcuser2@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_789")
|
|> 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)
|
# Create password user with target email (explicitly NO oidc_id)
|
||||||
password_user =
|
password_user =
|
||||||
|
|
@ -106,14 +117,14 @@ defmodule MvWeb.OidcEmailUpdateTest do
|
||||||
})
|
})
|
||||||
|
|
||||||
# Ensure it's a password-only user
|
# 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)
|
assert not is_nil(password_user.hashed_password)
|
||||||
# Force oidc_id to be nil to avoid any confusion
|
# Force oidc_id to be nil to avoid any confusion
|
||||||
{:ok, password_user} =
|
{:ok, password_user} =
|
||||||
password_user
|
password_user
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|> Ash.Changeset.force_change_attribute(:oidc_id, nil)
|
|> Ash.Changeset.force_change_attribute(:oidc_id, nil)
|
||||||
|> Ash.update()
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
assert is_nil(password_user.oidc_id)
|
assert is_nil(password_user.oidc_id)
|
||||||
|
|
||||||
|
|
@ -124,10 +135,13 @@ defmodule MvWeb.OidcEmailUpdateTest do
|
||||||
}
|
}
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{"access_token" => "test_token"}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{"access_token" => "test_token"}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Should fail with email update conflict error
|
# Should fail with email update conflict error
|
||||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
||||||
|
|
@ -150,7 +164,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "OIDC user updates email to email of different OIDC user" do
|
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
|
# Create first OIDC user
|
||||||
{:ok, _oidc_user1} =
|
{:ok, _oidc_user1} =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|
|
@ -158,7 +172,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
|
||||||
email: "oidcuser1@example.com"
|
email: "oidcuser1@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_aaa")
|
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_aaa")
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# Create second OIDC user with target email
|
# Create second OIDC user with target email
|
||||||
{:ok, _oidc_user2} =
|
{:ok, _oidc_user2} =
|
||||||
|
|
@ -167,7 +181,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
|
||||||
email: "oidcuser2@example.com"
|
email: "oidcuser2@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_bbb")
|
|> 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
|
# First OIDC user tries to update email to second user's email
|
||||||
user_info = %{
|
user_info = %{
|
||||||
|
|
@ -176,10 +190,13 @@ defmodule MvWeb.OidcEmailUpdateTest do
|
||||||
}
|
}
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{"access_token" => "test_token"}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{"access_token" => "test_token"}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Should fail with "already linked to different OIDC account" error
|
# Should fail with "already linked to different OIDC account" error
|
||||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
||||||
|
|
@ -201,14 +218,14 @@ defmodule MvWeb.OidcEmailUpdateTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "New OIDC user registration scenarios (for comparison)" do
|
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
|
# Create passwordless user
|
||||||
{:ok, passwordless_user} =
|
{:ok, passwordless_user} =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|> Ash.Changeset.for_create(:create_user, %{
|
|> Ash.Changeset.for_create(:create_user, %{
|
||||||
email: "passwordless@example.com"
|
email: "passwordless@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# New OIDC user tries to register
|
# New OIDC user tries to register
|
||||||
user_info = %{
|
user_info = %{
|
||||||
|
|
@ -217,10 +234,13 @@ defmodule MvWeb.OidcEmailUpdateTest do
|
||||||
}
|
}
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{"access_token" => "test_token"}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{"access_token" => "test_token"}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Should trigger PasswordVerificationRequired (linking flow)
|
# Should trigger PasswordVerificationRequired (linking flow)
|
||||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
||||||
|
|
@ -234,7 +254,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
|
||||||
end)
|
end)
|
||||||
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
|
# Create existing OIDC user
|
||||||
{:ok, _existing_oidc_user} =
|
{:ok, _existing_oidc_user} =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|
|
@ -242,7 +262,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
|
||||||
email: "existing@example.com"
|
email: "existing@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_existing")
|
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_existing")
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# New OIDC user tries to register with same email
|
# New OIDC user tries to register with same email
|
||||||
user_info = %{
|
user_info = %{
|
||||||
|
|
@ -251,10 +271,13 @@ defmodule MvWeb.OidcEmailUpdateTest do
|
||||||
}
|
}
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{"access_token" => "test_token"}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{"access_token" => "test_token"}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Should fail with "already linked to different OIDC account" error
|
# Should fail with "already linked to different OIDC account" error
|
||||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,11 @@ defmodule MvWeb.OidcIntegrationTest do
|
||||||
# Test OIDC callback scenarios by directly calling the actions
|
# Test OIDC callback scenarios by directly calling the actions
|
||||||
# This simulates what happens during real OIDC authentication
|
# 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
|
describe "OIDC sign-in scenarios" do
|
||||||
test "existing OIDC user with unchanged email can sign in" do
|
test "existing OIDC user with unchanged email can sign in" do
|
||||||
# Create user with OIDC ID
|
# Create user with OIDC ID
|
||||||
|
|
@ -20,11 +25,16 @@ defmodule MvWeb.OidcIntegrationTest do
|
||||||
}
|
}
|
||||||
|
|
||||||
# Test sign_in_with_rauthy action directly
|
# Test sign_in_with_rauthy action directly
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
{:ok, [found_user]} =
|
{:ok, [found_user]} =
|
||||||
Mv.Accounts.read_sign_in_with_rauthy(%{
|
Mv.Accounts.read_sign_in_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
assert found_user.id == user.id
|
assert found_user.id == user.id
|
||||||
assert to_string(found_user.email) == "existing@example.com"
|
assert to_string(found_user.email) == "existing@example.com"
|
||||||
|
|
@ -39,10 +49,15 @@ defmodule MvWeb.OidcIntegrationTest do
|
||||||
}
|
}
|
||||||
|
|
||||||
# Test register_with_rauthy action
|
# Test register_with_rauthy action
|
||||||
case Mv.Accounts.create_register_with_rauthy(%{
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
user_info: user_info,
|
|
||||||
oauth_tokens: %{}
|
case Mv.Accounts.create_register_with_rauthy(
|
||||||
}) do
|
%{
|
||||||
|
user_info: user_info,
|
||||||
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
) do
|
||||||
{:ok, new_user} ->
|
{:ok, new_user} ->
|
||||||
assert to_string(new_user.email) == "newuser@example.com"
|
assert to_string(new_user.email) == "newuser@example.com"
|
||||||
assert new_user.oidc_id == "brand_new_oidc_456"
|
assert new_user.oidc_id == "brand_new_oidc_456"
|
||||||
|
|
@ -73,11 +88,16 @@ defmodule MvWeb.OidcIntegrationTest do
|
||||||
}
|
}
|
||||||
|
|
||||||
# Should NOT find any user (security requirement)
|
# Should NOT find any user (security requirement)
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Accounts.read_sign_in_with_rauthy(%{
|
Mv.Accounts.read_sign_in_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
# Either returns empty list OR authentication error - both mean "user not found"
|
# Either returns empty list OR authentication error - both mean "user not found"
|
||||||
case result do
|
case result do
|
||||||
|
|
@ -107,11 +127,16 @@ defmodule MvWeb.OidcIntegrationTest do
|
||||||
"preferred_username" => "oidc.user@example.com"
|
"preferred_username" => "oidc.user@example.com"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
{:ok, [found_user]} =
|
{:ok, [found_user]} =
|
||||||
Mv.Accounts.read_sign_in_with_rauthy(%{
|
Mv.Accounts.read_sign_in_with_rauthy(
|
||||||
user_info: correct_user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: correct_user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
assert found_user.id == user.id
|
assert found_user.id == user.id
|
||||||
|
|
||||||
|
|
@ -122,10 +147,13 @@ defmodule MvWeb.OidcIntegrationTest do
|
||||||
}
|
}
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Accounts.read_sign_in_with_rauthy(%{
|
Mv.Accounts.read_sign_in_with_rauthy(
|
||||||
user_info: wrong_user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: wrong_user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
# Either returns empty list OR authentication error - both mean "user not found"
|
# Either returns empty list OR authentication error - both mean "user not found"
|
||||||
case result do
|
case result do
|
||||||
|
|
@ -154,11 +182,16 @@ defmodule MvWeb.OidcIntegrationTest do
|
||||||
"preferred_username" => "empty.oidc@example.com"
|
"preferred_username" => "empty.oidc@example.com"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Accounts.read_sign_in_with_rauthy(%{
|
Mv.Accounts.read_sign_in_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
# Either returns empty list OR authentication error - both mean "user not found"
|
# Either returns empty list OR authentication error - both mean "user not found"
|
||||||
case result do
|
case result do
|
||||||
|
|
@ -189,11 +222,16 @@ defmodule MvWeb.OidcIntegrationTest do
|
||||||
"preferred_username" => "conflict@example.com"
|
"preferred_username" => "conflict@example.com"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
# Should fail with hard error (not PasswordVerificationRequired)
|
# Should fail with hard error (not PasswordVerificationRequired)
|
||||||
# This prevents someone with OIDC provider B from taking over an account
|
# 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"
|
"preferred_username" => "nosub@example.com"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
assert {:error,
|
assert {:error,
|
||||||
%Ash.Error.Invalid{
|
%Ash.Error.Invalid{
|
||||||
|
|
@ -239,11 +282,16 @@ defmodule MvWeb.OidcIntegrationTest do
|
||||||
"sub" => "noemail_oidc_123"
|
"sub" => "noemail_oidc_123"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
||||||
|
|
||||||
|
|
@ -264,11 +312,16 @@ defmodule MvWeb.OidcIntegrationTest do
|
||||||
"preferred_username" => "new@example.com"
|
"preferred_username" => "new@example.com"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
assert user.id == existing_user.id
|
assert user.id == existing_user.id
|
||||||
assert to_string(user.email) == "new@example.com"
|
assert to_string(user.email) == "new@example.com"
|
||||||
|
|
@ -281,11 +334,16 @@ defmodule MvWeb.OidcIntegrationTest do
|
||||||
"preferred_username" => "altid@example.com"
|
"preferred_username" => "altid@example.com"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
assert user.oidc_id == "alt_oidc_id_123"
|
assert user.oidc_id == "alt_oidc_id_123"
|
||||||
assert to_string(user.email) == "altid@example.com"
|
assert to_string(user.email) == "altid@example.com"
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,15 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|
||||||
use MvWeb.ConnCase, async: true
|
use MvWeb.ConnCase, async: true
|
||||||
require Ash.Query
|
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
|
describe "OIDC login with existing email (no oidc_id) - Email Collision" do
|
||||||
@tag :test_proposal
|
@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
|
# Create password-only user
|
||||||
existing_user =
|
existing_user =
|
||||||
create_test_user(%{
|
create_test_user(%{
|
||||||
|
|
@ -26,10 +32,13 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|
||||||
}
|
}
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Should fail with PasswordVerificationRequired error
|
# Should fail with PasswordVerificationRequired error
|
||||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
||||||
|
|
@ -47,7 +56,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag :test_proposal
|
@tag :test_proposal
|
||||||
test "PasswordVerificationRequired error contains necessary context" do
|
test "PasswordVerificationRequired error contains necessary context", %{actor: actor} do
|
||||||
existing_user =
|
existing_user =
|
||||||
create_test_user(%{
|
create_test_user(%{
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
|
|
@ -61,10 +70,13 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|
||||||
}
|
}
|
||||||
|
|
||||||
{:error, %Ash.Error.Invalid{errors: errors}} =
|
{:error, %Ash.Error.Invalid{errors: errors}} =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
password_error =
|
password_error =
|
||||||
Enum.find(errors, fn err ->
|
Enum.find(errors, fn err ->
|
||||||
|
|
@ -78,7 +90,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag :test_proposal
|
@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
|
# Create password user
|
||||||
user =
|
user =
|
||||||
create_test_user(%{
|
create_test_user(%{
|
||||||
|
|
@ -97,12 +109,12 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|
||||||
{:ok, updated_user} =
|
{:ok, updated_user} =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|> Ash.Query.filter(id == ^user.id)
|
|> Ash.Query.filter(id == ^user.id)
|
||||||
|> Ash.read_one!()
|
|> Ash.read_one!(actor: actor)
|
||||||
|> Ash.Changeset.for_update(:link_oidc_id, %{
|
|> Ash.Changeset.for_update(:link_oidc_id, %{
|
||||||
oidc_id: user_info["sub"],
|
oidc_id: user_info["sub"],
|
||||||
oidc_user_info: user_info
|
oidc_user_info: user_info
|
||||||
})
|
})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
assert updated_user.id == user.id
|
assert updated_user.id == user.id
|
||||||
assert updated_user.oidc_id == "linked_oidc_555"
|
assert updated_user.oidc_id == "linked_oidc_555"
|
||||||
|
|
@ -112,7 +124,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag :test_proposal
|
@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,
|
# This test verifies that if password verification fails,
|
||||||
# the oidc_id should NOT be set
|
# 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
|
# before link_oidc_id is called, so here we just verify the user state
|
||||||
|
|
||||||
# User should still have no oidc_id (no linking happened)
|
# 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 is_nil(unchanged_user.oidc_id)
|
||||||
assert unchanged_user.hashed_password == user.hashed_password
|
assert unchanged_user.hashed_password == user.hashed_password
|
||||||
end
|
end
|
||||||
|
|
@ -139,7 +151,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|
||||||
|
|
||||||
describe "OIDC login with email of user having different oidc_id - Account Conflict" do
|
describe "OIDC login with email of user having different oidc_id - Account Conflict" do
|
||||||
@tag :test_proposal
|
@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
|
# User already linked to OIDC provider A
|
||||||
_existing_user =
|
_existing_user =
|
||||||
create_test_user(%{
|
create_test_user(%{
|
||||||
|
|
@ -155,10 +167,13 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|
||||||
}
|
}
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Should fail - cannot link different OIDC account to same email
|
# Should fail - cannot link different OIDC account to same email
|
||||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
||||||
|
|
@ -171,7 +186,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag :test_proposal
|
@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 =
|
user =
|
||||||
create_test_user(%{
|
create_test_user(%{
|
||||||
email: "oidc@example.com",
|
email: "oidc@example.com",
|
||||||
|
|
@ -186,10 +201,13 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|
||||||
|
|
||||||
# This should work via upsert
|
# This should work via upsert
|
||||||
{:ok, updated_user} =
|
{:ok, updated_user} =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
assert updated_user.id == user.id
|
assert updated_user.id == user.id
|
||||||
assert updated_user.oidc_id == "oidc_stable_789"
|
assert updated_user.oidc_id == "oidc_stable_789"
|
||||||
|
|
@ -199,7 +217,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|
||||||
|
|
||||||
describe "Email update during OIDC linking" do
|
describe "Email update during OIDC linking" do
|
||||||
@tag :test_proposal
|
@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
|
# Password user with old email
|
||||||
user =
|
user =
|
||||||
create_test_user(%{
|
create_test_user(%{
|
||||||
|
|
@ -218,19 +236,19 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|
||||||
{:ok, updated_user} =
|
{:ok, updated_user} =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|> Ash.Query.filter(id == ^user.id)
|
|> Ash.Query.filter(id == ^user.id)
|
||||||
|> Ash.read_one!()
|
|> Ash.read_one!(actor: actor)
|
||||||
|> Ash.Changeset.for_update(:link_oidc_id, %{
|
|> Ash.Changeset.for_update(:link_oidc_id, %{
|
||||||
oidc_id: user_info["sub"],
|
oidc_id: user_info["sub"],
|
||||||
oidc_user_info: user_info
|
oidc_user_info: user_info
|
||||||
})
|
})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
assert updated_user.oidc_id == "oidc_link_999"
|
assert updated_user.oidc_id == "oidc_link_999"
|
||||||
assert to_string(updated_user.email) == "newemail@example.com"
|
assert to_string(updated_user.email) == "newemail@example.com"
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag :test_proposal
|
@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
|
# Create member
|
||||||
member =
|
member =
|
||||||
Ash.Seed.seed!(Mv.Membership.Member, %{
|
Ash.Seed.seed!(Mv.Membership.Member, %{
|
||||||
|
|
@ -257,25 +275,25 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|
||||||
{:ok, updated_user} =
|
{:ok, updated_user} =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|> Ash.Query.filter(id == ^user.id)
|
|> Ash.Query.filter(id == ^user.id)
|
||||||
|> Ash.read_one!()
|
|> Ash.read_one!(actor: actor)
|
||||||
|> Ash.Changeset.for_update(:link_oidc_id, %{
|
|> Ash.Changeset.for_update(:link_oidc_id, %{
|
||||||
oidc_id: user_info["sub"],
|
oidc_id: user_info["sub"],
|
||||||
oidc_user_info: user_info
|
oidc_user_info: user_info
|
||||||
})
|
})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
# Verify user email changed
|
# Verify user email changed
|
||||||
assert to_string(updated_user.email) == "newemail@example.com"
|
assert to_string(updated_user.email) == "newemail@example.com"
|
||||||
|
|
||||||
# Verify member email was synced
|
# 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"
|
assert to_string(updated_member.email) == "newemail@example.com"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "Edge cases" do
|
describe "Edge cases" do
|
||||||
@tag :test_proposal
|
@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 =
|
_user =
|
||||||
create_test_user(%{
|
create_test_user(%{
|
||||||
email: "empty@example.com",
|
email: "empty@example.com",
|
||||||
|
|
@ -290,10 +308,13 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|
||||||
}
|
}
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Should trigger PasswordVerificationRequired (empty string = no OIDC)
|
# Should trigger PasswordVerificationRequired (empty string = no OIDC)
|
||||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
||||||
|
|
@ -307,7 +328,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag :test_proposal
|
@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
|
# User 1 with OIDC
|
||||||
_user1 =
|
_user1 =
|
||||||
create_test_user(%{
|
create_test_user(%{
|
||||||
|
|
@ -323,7 +344,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|
||||||
email: "user2@example.com"
|
email: "user2@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.Changeset.force_change_attribute(:oidc_id, "shared_oidc_333")
|
|> 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
|
# Should fail due to unique constraint on oidc_id
|
||||||
assert match?({:error, %Ash.Error.Invalid{}}, result)
|
assert match?({:error, %Ash.Error.Invalid{}}, result)
|
||||||
|
|
@ -337,14 +358,16 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "OIDC login with passwordless user - Requires Linking Flow" do
|
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)
|
# Create user without password (e.g., invited user)
|
||||||
{:ok, existing_user} =
|
{:ok, existing_user} =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|> Ash.Changeset.for_create(:create_user, %{
|
|> Ash.Changeset.for_create(:create_user, %{
|
||||||
email: "invited@example.com"
|
email: "invited@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# Verify user has no password and no oidc_id
|
# Verify user has no password and no oidc_id
|
||||||
assert is_nil(existing_user.hashed_password)
|
assert is_nil(existing_user.hashed_password)
|
||||||
|
|
@ -372,14 +395,14 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|
||||||
end)
|
end)
|
||||||
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
|
# Create user without password first
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|> Ash.Changeset.for_create(:create_user, %{
|
|> Ash.Changeset.for_create(:create_user, %{
|
||||||
email: "added-password@example.com"
|
email: "added-password@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# User sets password later (using admin action)
|
# User sets password later (using admin action)
|
||||||
{:ok, user_with_password} =
|
{:ok, user_with_password} =
|
||||||
|
|
@ -387,7 +410,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|
||||||
|> Ash.Changeset.for_update(:admin_set_password, %{
|
|> Ash.Changeset.for_update(:admin_set_password, %{
|
||||||
password: "newpassword123"
|
password: "newpassword123"
|
||||||
})
|
})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
assert not is_nil(user_with_password.hashed_password)
|
assert not is_nil(user_with_password.hashed_password)
|
||||||
|
|
||||||
|
|
@ -398,10 +421,13 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|
||||||
}
|
}
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{"access_token" => "test_token"}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{"access_token" => "test_token"}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Should fail with PasswordVerificationRequired
|
# Should fail with PasswordVerificationRequired
|
||||||
assert {:error, %Ash.Error.Invalid{}} = result
|
assert {:error, %Ash.Error.Invalid{}} = result
|
||||||
|
|
@ -414,7 +440,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "OIDC login with different oidc_id - Hard Error" do
|
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
|
# Create user with existing OIDC ID
|
||||||
{:ok, existing_user} =
|
{:ok, existing_user} =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|
|
@ -422,7 +448,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|
||||||
email: "already-linked@example.com"
|
email: "already-linked@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.Changeset.force_change_attribute(:oidc_id, "original_oidc_999")
|
|> Ash.Changeset.force_change_attribute(:oidc_id, "original_oidc_999")
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert existing_user.oidc_id == "original_oidc_999"
|
assert existing_user.oidc_id == "original_oidc_999"
|
||||||
|
|
||||||
|
|
@ -433,10 +459,13 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|
||||||
}
|
}
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{"access_token" => "test_token"}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{"access_token" => "test_token"}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Should fail with hard error (not PasswordVerificationRequired)
|
# Should fail with hard error (not PasswordVerificationRequired)
|
||||||
assert {:error, %Ash.Error.Invalid{}} = result
|
assert {:error, %Ash.Error.Invalid{}} = result
|
||||||
|
|
@ -459,7 +488,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|
||||||
end)
|
end)
|
||||||
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
|
# Create user with password AND existing OIDC ID
|
||||||
existing_user =
|
existing_user =
|
||||||
create_test_user(%{
|
create_test_user(%{
|
||||||
|
|
@ -478,10 +507,13 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|
||||||
}
|
}
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Mv.Accounts.create_register_with_rauthy(%{
|
Mv.Accounts.create_register_with_rauthy(
|
||||||
user_info: user_info,
|
%{
|
||||||
oauth_tokens: %{"access_token" => "test_token"}
|
user_info: user_info,
|
||||||
})
|
oauth_tokens: %{"access_token" => "test_token"}
|
||||||
|
},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Should fail - cannot link different OIDC ID
|
# Should fail - cannot link different OIDC ID
|
||||||
assert {:error, %Ash.Error.Invalid{}} = result
|
assert {:error, %Ash.Error.Invalid{}} = result
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,20 @@ defmodule MvWeb.OidcPasswordlessLinkingTest do
|
||||||
"""
|
"""
|
||||||
use MvWeb.ConnCase, async: true
|
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
|
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)
|
# Create user without password (e.g., invited user)
|
||||||
{:ok, existing_user} =
|
{:ok, existing_user} =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|> Ash.Changeset.for_create(:create_user, %{
|
|> Ash.Changeset.for_create(:create_user, %{
|
||||||
email: "invited@example.com"
|
email: "invited@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# Verify user has no password and no oidc_id
|
# Verify user has no password and no oidc_id
|
||||||
assert is_nil(existing_user.hashed_password)
|
assert is_nil(existing_user.hashed_password)
|
||||||
|
|
@ -31,7 +36,7 @@ defmodule MvWeb.OidcPasswordlessLinkingTest do
|
||||||
"preferred_username" => "invited@example.com"
|
"preferred_username" => "invited@example.com"
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|> Ash.update()
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
# User should now have oidc_id linked
|
# User should now have oidc_id linked
|
||||||
assert linked_user.oidc_id == "auto_link_oidc_123"
|
assert linked_user.oidc_id == "auto_link_oidc_123"
|
||||||
|
|
@ -47,20 +52,22 @@ defmodule MvWeb.OidcPasswordlessLinkingTest do
|
||||||
},
|
},
|
||||||
oauth_tokens: %{"access_token" => "test_token"}
|
oauth_tokens: %{"access_token" => "test_token"}
|
||||||
})
|
})
|
||||||
|> Ash.read_one()
|
|> Ash.read_one(actor: actor)
|
||||||
|
|
||||||
assert {:ok, signed_in_user} = result
|
assert {:ok, signed_in_user} = result
|
||||||
assert signed_in_user.id == existing_user.id
|
assert signed_in_user.id == existing_user.id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "passwordless user triggers PasswordVerificationRequired for linking flow" do
|
test "passwordless user triggers PasswordVerificationRequired for linking flow", %{
|
||||||
|
actor: actor
|
||||||
|
} do
|
||||||
# Create passwordless user
|
# Create passwordless user
|
||||||
{:ok, existing_user} =
|
{:ok, existing_user} =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|> Ash.Changeset.for_create(:create_user, %{
|
|> Ash.Changeset.for_create(:create_user, %{
|
||||||
email: "passwordless@example.com"
|
email: "passwordless@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert is_nil(existing_user.hashed_password)
|
assert is_nil(existing_user.hashed_password)
|
||||||
assert is_nil(existing_user.oidc_id)
|
assert is_nil(existing_user.oidc_id)
|
||||||
|
|
@ -95,7 +102,7 @@ defmodule MvWeb.OidcPasswordlessLinkingTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "User with different OIDC ID - Hard Error" do
|
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
|
# Create user with existing OIDC ID
|
||||||
{:ok, _existing_user} =
|
{:ok, _existing_user} =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|
|
@ -103,7 +110,7 @@ defmodule MvWeb.OidcPasswordlessLinkingTest do
|
||||||
email: "already-linked@example.com"
|
email: "already-linked@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.Changeset.force_change_attribute(:oidc_id, "original_oidc_999")
|
|> 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
|
# Try to register with same email but different OIDC ID
|
||||||
user_info = %{
|
user_info = %{
|
||||||
|
|
@ -138,7 +145,7 @@ defmodule MvWeb.OidcPasswordlessLinkingTest do
|
||||||
end)
|
end)
|
||||||
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
|
# Create passwordless user with OIDC ID
|
||||||
{:ok, existing_user} =
|
{:ok, existing_user} =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|
|
@ -146,7 +153,7 @@ defmodule MvWeb.OidcPasswordlessLinkingTest do
|
||||||
email: "passwordless-linked@example.com"
|
email: "passwordless-linked@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.Changeset.force_change_attribute(:oidc_id, "first_oidc_777")
|
|> Ash.Changeset.force_change_attribute(:oidc_id, "first_oidc_777")
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
assert is_nil(existing_user.hashed_password)
|
assert is_nil(existing_user.hashed_password)
|
||||||
assert existing_user.oidc_id == "first_oidc_777"
|
assert existing_user.oidc_id == "first_oidc_777"
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,11 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
||||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||||
alias Mv.MembershipFees.CalendarCycles
|
alias Mv.MembershipFees.CalendarCycles
|
||||||
|
|
||||||
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
%{actor: system_actor}
|
||||||
|
end
|
||||||
|
|
||||||
describe "format_currency/1" do
|
describe "format_currency/1" do
|
||||||
test "formats decimal amount correctly" do
|
test "formats decimal amount correctly" do
|
||||||
assert MembershipFeeHelpers.format_currency(Decimal.new("60.00")) == "60,00 €"
|
assert MembershipFeeHelpers.format_currency(Decimal.new("60.00")) == "60,00 €"
|
||||||
|
|
@ -63,7 +68,7 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "get_last_completed_cycle/2" do
|
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
|
# Create test data
|
||||||
fee_type =
|
fee_type =
|
||||||
Mv.MembershipFees.MembershipFeeType
|
Mv.MembershipFees.MembershipFeeType
|
||||||
|
|
@ -72,7 +77,7 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
||||||
amount: Decimal.new("50.00"),
|
amount: Decimal.new("50.00"),
|
||||||
interval: :yearly
|
interval: :yearly
|
||||||
})
|
})
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
|
|
||||||
# Create member without fee type first to avoid auto-generation
|
# Create member without fee type first to avoid auto-generation
|
||||||
member =
|
member =
|
||||||
|
|
@ -83,21 +88,21 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
||||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||||
join_date: ~D[2022-01-01]
|
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)
|
# Assign fee type after member creation (this may generate cycles, but we'll create our own)
|
||||||
member =
|
member =
|
||||||
member
|
member
|
||||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
|> 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
|
# Delete any auto-generated cycles first
|
||||||
cycles =
|
cycles =
|
||||||
Mv.MembershipFees.MembershipFeeCycle
|
Mv.MembershipFees.MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member.id)
|
|> 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
|
# Create cycles manually
|
||||||
_cycle_2022 =
|
_cycle_2022 =
|
||||||
|
|
@ -109,7 +114,7 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
||||||
membership_fee_type_id: fee_type.id,
|
membership_fee_type_id: fee_type.id,
|
||||||
status: :paid
|
status: :paid
|
||||||
})
|
})
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
|
|
||||||
cycle_2023 =
|
cycle_2023 =
|
||||||
Mv.MembershipFees.MembershipFeeCycle
|
Mv.MembershipFees.MembershipFeeCycle
|
||||||
|
|
@ -120,7 +125,7 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
||||||
membership_fee_type_id: fee_type.id,
|
membership_fee_type_id: fee_type.id,
|
||||||
status: :paid
|
status: :paid
|
||||||
})
|
})
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
|
|
||||||
# Load cycles with membership_fee_type relationship
|
# Load cycles with membership_fee_type relationship
|
||||||
member =
|
member =
|
||||||
|
|
@ -135,7 +140,7 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
||||||
assert last_cycle.id == cycle_2023.id
|
assert last_cycle.id == cycle_2023.id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns nil if no cycles exist" do
|
test "returns nil if no cycles exist", %{actor: actor} do
|
||||||
fee_type =
|
fee_type =
|
||||||
Mv.MembershipFees.MembershipFeeType
|
Mv.MembershipFees.MembershipFeeType
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
|
@ -143,7 +148,7 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
||||||
amount: Decimal.new("50.00"),
|
amount: Decimal.new("50.00"),
|
||||||
interval: :yearly
|
interval: :yearly
|
||||||
})
|
})
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
|
|
||||||
# Create member without fee type first
|
# Create member without fee type first
|
||||||
member =
|
member =
|
||||||
|
|
@ -153,21 +158,21 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
email: "test#{System.unique_integer([:positive])}@example.com"
|
email: "test#{System.unique_integer([:positive])}@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
|
|
||||||
# Assign fee type
|
# Assign fee type
|
||||||
member =
|
member =
|
||||||
member
|
member
|
||||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||||
|> Ash.update!()
|
|> Ash.update!(actor: actor)
|
||||||
|
|
||||||
# Delete any auto-generated cycles
|
# Delete any auto-generated cycles
|
||||||
cycles =
|
cycles =
|
||||||
Mv.MembershipFees.MembershipFeeCycle
|
Mv.MembershipFees.MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member.id)
|
|> 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)
|
# Load cycles and fee type (will be empty)
|
||||||
member =
|
member =
|
||||||
|
|
@ -181,7 +186,7 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "get_current_cycle/2" do
|
describe "get_current_cycle/2" do
|
||||||
test "returns current cycle for member" do
|
test "returns current cycle for member", %{actor: actor} do
|
||||||
fee_type =
|
fee_type =
|
||||||
Mv.MembershipFees.MembershipFeeType
|
Mv.MembershipFees.MembershipFeeType
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
|
@ -189,7 +194,7 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
||||||
amount: Decimal.new("50.00"),
|
amount: Decimal.new("50.00"),
|
||||||
interval: :yearly
|
interval: :yearly
|
||||||
})
|
})
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
|
|
||||||
# Create member without fee type first
|
# Create member without fee type first
|
||||||
member =
|
member =
|
||||||
|
|
@ -200,21 +205,21 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
||||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||||
join_date: ~D[2023-01-01]
|
join_date: ~D[2023-01-01]
|
||||||
})
|
})
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
|
|
||||||
# Assign fee type
|
# Assign fee type
|
||||||
member =
|
member =
|
||||||
member
|
member
|
||||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||||
|> Ash.update!()
|
|> Ash.update!(actor: actor)
|
||||||
|
|
||||||
# Delete any auto-generated cycles
|
# Delete any auto-generated cycles
|
||||||
cycles =
|
cycles =
|
||||||
Mv.MembershipFees.MembershipFeeCycle
|
Mv.MembershipFees.MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member.id)
|
|> 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()
|
today = Date.utc_today()
|
||||||
current_year_start = %{today | month: 1, day: 1}
|
current_year_start = %{today | month: 1, day: 1}
|
||||||
|
|
@ -228,7 +233,7 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
||||||
membership_fee_type_id: fee_type.id,
|
membership_fee_type_id: fee_type.id,
|
||||||
status: :unpaid
|
status: :unpaid
|
||||||
})
|
})
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
|
|
||||||
# Load cycles with membership_fee_type relationship
|
# Load cycles with membership_fee_type relationship
|
||||||
member =
|
member =
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
||||||
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Create admin user for testing
|
# Create admin user for testing
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|
|
@ -26,7 +28,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
||||||
email: "admin#{System.unique_integer([:positive])}@mv.local",
|
email: "admin#{System.unique_integer([:positive])}@mv.local",
|
||||||
password: "testpassword123"
|
password: "testpassword123"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
conn = log_in_user(build_conn(), user)
|
conn = log_in_user(build_conn(), user)
|
||||||
%{conn: conn, user: user}
|
%{conn: conn, user: user}
|
||||||
|
|
@ -156,14 +158,16 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
||||||
# Should show success message
|
# Should show success message
|
||||||
assert render(view) =~ "Data field deleted successfully"
|
assert render(view) =~ "Data field deleted successfully"
|
||||||
|
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Custom field should be gone from database
|
# 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)
|
# 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
|
# Member should still exist
|
||||||
assert {:ok, _} = Ash.get(Member, member.id)
|
assert {:ok, _} = Ash.get(Member, member.id, actor: system_actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "button remains disabled and custom field not deleted when slug doesn't match", %{
|
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))/
|
assert html =~ ~r/disabled(?:=""|(?!\w))/
|
||||||
|
|
||||||
# Custom field should still exist since deletion couldn't proceed
|
# 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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -214,38 +219,45 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
||||||
refute has_element?(view, "#delete-custom-field-modal")
|
refute has_element?(view, "#delete-custom-field-modal")
|
||||||
|
|
||||||
# Custom field should still exist
|
# 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
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper functions
|
# Helper functions
|
||||||
defp create_member do
|
defp create_member do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
Member
|
Member
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|> Ash.Changeset.for_create(:create_member, %{
|
||||||
first_name: "Test",
|
first_name: "Test",
|
||||||
last_name: "User#{System.unique_integer([:positive])}",
|
last_name: "User#{System.unique_integer([:positive])}",
|
||||||
email: "test#{System.unique_integer([:positive])}@example.com"
|
email: "test#{System.unique_integer([:positive])}@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp create_custom_field(name, value_type) do
|
defp create_custom_field(name, value_type) do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "#{name}_#{System.unique_integer([:positive])}",
|
name: "#{name}_#{System.unique_integer([:positive])}",
|
||||||
value_type: value_type
|
value_type: value_type
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp create_custom_field_value(member, custom_field, value) do
|
defp create_custom_field_value(member, custom_field, value) do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
member_id: member.id,
|
member_id: member.id,
|
||||||
custom_field_id: custom_field.id,
|
custom_field_id: custom_field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => value}
|
value: %{"_union_type" => "string", "_union_value" => value}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp log_in_user(conn, user) do
|
defp log_in_user(conn, user) do
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
setup %{conn: conn} do
|
setup %{conn: conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Create admin user
|
# Create admin user
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|
|
@ -19,7 +21,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|
||||||
email: "admin#{System.unique_integer([:positive])}@mv.local",
|
email: "admin#{System.unique_integer([:positive])}@mv.local",
|
||||||
password: "testpassword123"
|
password: "testpassword123"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
authenticated_conn = conn_with_password_user(conn, user)
|
authenticated_conn = conn_with_password_user(conn, user)
|
||||||
%{conn: authenticated_conn, user: user}
|
%{conn: authenticated_conn, user: user}
|
||||||
|
|
@ -27,6 +29,8 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|
||||||
|
|
||||||
# Helper to create a membership fee type
|
# Helper to create a membership fee type
|
||||||
defp create_fee_type(attrs) do
|
defp create_fee_type(attrs) do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||||
amount: Decimal.new("50.00"),
|
amount: Decimal.new("50.00"),
|
||||||
|
|
@ -37,11 +41,13 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|
||||||
|
|
||||||
MembershipFeeType
|
MembershipFeeType
|
||||||
|> Ash.Changeset.for_create(:create, attrs)
|
|> Ash.Changeset.for_create(:create, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: system_actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a member
|
# Helper to create a member
|
||||||
defp create_member(attrs) do
|
defp create_member(attrs) do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
first_name: "Test",
|
first_name: "Test",
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
|
|
@ -52,7 +58,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|
||||||
|
|
||||||
Member
|
Member
|
||||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: system_actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "create form" do
|
describe "create form" do
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|
||||||
# No custom setup needed
|
# No custom setup needed
|
||||||
|
|
||||||
# Helper to create a membership fee type
|
# 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 = %{
|
default_attrs = %{
|
||||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||||
amount: Decimal.new("50.00"),
|
amount: Decimal.new("50.00"),
|
||||||
|
|
@ -26,7 +27,7 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|
||||||
|
|
||||||
MembershipFeeType
|
MembershipFeeType
|
||||||
|> Ash.Changeset.for_create(:create, attrs)
|
|> Ash.Changeset.for_create(:create, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: admin_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a member
|
# Helper to create a member
|
||||||
|
|
@ -48,12 +49,21 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "list display" do
|
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 =
|
_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 =
|
_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")
|
{:ok, _view, html} = live(conn, "/membership_fee_types")
|
||||||
|
|
||||||
|
|
@ -65,7 +75,7 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "member count column shows correct count", %{conn: conn, current_user: admin_user} do
|
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
|
# Create 3 members with this fee type
|
||||||
Enum.each(1..3, fn _ ->
|
Enum.each(1..3, fn _ ->
|
||||||
|
|
@ -88,8 +98,8 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|
||||||
assert to == "/membership_fee_types/new"
|
assert to == "/membership_fee_types/new"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "edit button per row navigates to edit form", %{conn: conn} do
|
test "edit button per row navigates to edit form", %{conn: conn, current_user: admin_user} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, admin_user)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/membership_fee_types")
|
{:ok, view, _html} = live(conn, "/membership_fee_types")
|
||||||
|
|
||||||
|
|
@ -104,7 +114,7 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|
||||||
|
|
||||||
describe "delete functionality" do
|
describe "delete functionality" do
|
||||||
test "delete button disabled if type is in use", %{conn: conn, current_user: admin_user} 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)
|
create_member(%{membership_fee_type_id: fee_type.id}, admin_user)
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/membership_fee_types")
|
{:ok, _view, html} = live(conn, "/membership_fee_types")
|
||||||
|
|
@ -113,8 +123,8 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|
||||||
assert html =~ "disabled" || html =~ "cursor-not-allowed"
|
assert html =~ "disabled" || html =~ "cursor-not-allowed"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "delete button works if type is not in use", %{conn: conn} do
|
test "delete button works if type is not 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)
|
||||||
# No members assigned
|
# No members assigned
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/membership_fee_types")
|
{: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}']")
|
|> element("button[phx-click='delete'][phx-value-id='#{fee_type.id}']")
|
||||||
|> render_click()
|
|> 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{}]}} =
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@ defmodule MvWeb.ProfileNavigationTest do
|
||||||
use MvWeb.ConnCase, async: true
|
use MvWeb.ConnCase, async: true
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
%{actor: system_actor}
|
||||||
|
end
|
||||||
|
|
||||||
describe "profile navigation" do
|
describe "profile navigation" do
|
||||||
test "clicking profile button redirects to current user profile", %{conn: conn} do
|
test "clicking profile button redirects to current user profile", %{conn: conn} do
|
||||||
# Setup: Create and login a user
|
# Setup: Create and login a user
|
||||||
|
|
@ -60,7 +65,7 @@ defmodule MvWeb.ProfileNavigationTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "profile navigation with OIDC user" do
|
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
|
# Setup: Create OIDC user with sub claim
|
||||||
user_info = %{
|
user_info = %{
|
||||||
"sub" => "oidc_123",
|
"sub" => "oidc_123",
|
||||||
|
|
@ -78,7 +83,7 @@ defmodule MvWeb.ProfileNavigationTest do
|
||||||
user_info: user_info,
|
user_info: user_info,
|
||||||
oauth_tokens: oauth_tokens
|
oauth_tokens: oauth_tokens
|
||||||
})
|
})
|
||||||
|> Ash.create!(domain: Mv.Accounts)
|
|> Ash.create!(domain: Mv.Accounts, actor: actor)
|
||||||
|
|
||||||
# Login user via OIDC
|
# Login user via OIDC
|
||||||
conn = sign_in_user_via_oidc(conn, user)
|
conn = sign_in_user_via_oidc(conn, user)
|
||||||
|
|
@ -94,7 +99,10 @@ defmodule MvWeb.ProfileNavigationTest do
|
||||||
assert html =~ "Not enabled"
|
assert html =~ "Not enabled"
|
||||||
end
|
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
|
# Create password user
|
||||||
password_user =
|
password_user =
|
||||||
create_test_user(%{
|
create_test_user(%{
|
||||||
|
|
@ -119,7 +127,7 @@ defmodule MvWeb.ProfileNavigationTest do
|
||||||
user_info: user_info,
|
user_info: user_info,
|
||||||
oauth_tokens: oauth_tokens
|
oauth_tokens: oauth_tokens
|
||||||
})
|
})
|
||||||
|> Ash.create!(domain: Mv.Accounts)
|
|> Ash.create!(domain: Mv.Accounts, actor: actor)
|
||||||
|
|
||||||
# Test with password user
|
# Test with password user
|
||||||
conn_password = conn_with_password_user(conn, password_user)
|
conn_password = conn_with_password_user(conn, password_user)
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ defmodule MvWeb.RoleLive.ShowTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create admin user with admin role
|
# Helper to create admin user with admin role
|
||||||
defp create_admin_user(conn) do
|
defp create_admin_user(conn, actor) do
|
||||||
# Create admin role
|
# Create admin role
|
||||||
admin_role =
|
admin_role =
|
||||||
case Authorization.list_roles() do
|
case Authorization.list_roles() do
|
||||||
|
|
@ -69,17 +69,17 @@ defmodule MvWeb.RoleLive.ShowTest do
|
||||||
email: "admin#{System.unique_integer([:positive])}@mv.local",
|
email: "admin#{System.unique_integer([:positive])}@mv.local",
|
||||||
password: "testpassword123"
|
password: "testpassword123"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# Assign admin role using manage_relationship
|
# Assign admin role using manage_relationship
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
user
|
user
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
|> 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)
|
# 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
|
# Store user with role in session for LiveView
|
||||||
conn = conn_with_password_user(conn, user_with_role)
|
conn = conn_with_password_user(conn, user_with_role)
|
||||||
|
|
@ -88,8 +88,9 @@ defmodule MvWeb.RoleLive.ShowTest do
|
||||||
|
|
||||||
describe "mount and display" do
|
describe "mount and display" do
|
||||||
setup %{conn: conn} do
|
setup %{conn: conn} do
|
||||||
{conn, _user, _admin_role} = create_admin_user(conn)
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
%{conn: conn}
|
{conn, _user, _admin_role} = create_admin_user(conn, system_actor)
|
||||||
|
%{conn: conn, actor: system_actor}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "mounts successfully with valid role ID", %{conn: conn} do
|
test "mounts successfully with valid role ID", %{conn: conn} do
|
||||||
|
|
@ -135,7 +136,7 @@ defmodule MvWeb.RoleLive.ShowTest do
|
||||||
assert html =~ gettext("Permission Set")
|
assert html =~ gettext("Permission Set")
|
||||||
end
|
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 =
|
system_role =
|
||||||
Role
|
Role
|
||||||
|> Ash.Changeset.for_create(:create_role, %{
|
|> Ash.Changeset.for_create(:create_role, %{
|
||||||
|
|
@ -143,7 +144,7 @@ defmodule MvWeb.RoleLive.ShowTest do
|
||||||
permission_set_name: "own_data"
|
permission_set_name: "own_data"
|
||||||
})
|
})
|
||||||
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|
|> 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}")
|
{:ok, _view, html} = live(conn, "/admin/roles/#{system_role.id}")
|
||||||
|
|
||||||
|
|
@ -172,8 +173,9 @@ defmodule MvWeb.RoleLive.ShowTest do
|
||||||
|
|
||||||
describe "navigation" do
|
describe "navigation" do
|
||||||
setup %{conn: conn} do
|
setup %{conn: conn} do
|
||||||
{conn, _user, _admin_role} = create_admin_user(conn)
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
%{conn: conn}
|
{conn, _user, _admin_role} = create_admin_user(conn, system_actor)
|
||||||
|
%{conn: conn, actor: system_actor}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "back button navigates to role list", %{conn: conn} do
|
test "back button navigates to role list", %{conn: conn} do
|
||||||
|
|
@ -209,8 +211,9 @@ defmodule MvWeb.RoleLive.ShowTest do
|
||||||
|
|
||||||
describe "error handling" do
|
describe "error handling" do
|
||||||
setup %{conn: conn} do
|
setup %{conn: conn} do
|
||||||
{conn, _user, _admin_role} = create_admin_user(conn)
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
%{conn: conn}
|
{conn, _user, _admin_role} = create_admin_user(conn, system_actor)
|
||||||
|
%{conn: conn, actor: system_actor}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "redirects to role list with error for invalid role ID", %{conn: conn} do
|
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
|
describe "delete functionality" do
|
||||||
setup %{conn: conn} do
|
setup %{conn: conn} do
|
||||||
{conn, _user, _admin_role} = create_admin_user(conn)
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
%{conn: conn}
|
{conn, _user, _admin_role} = create_admin_user(conn, system_actor)
|
||||||
|
%{conn: conn, actor: system_actor}
|
||||||
end
|
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 =
|
system_role =
|
||||||
Role
|
Role
|
||||||
|> Ash.Changeset.for_create(:create_role, %{
|
|> Ash.Changeset.for_create(:create_role, %{
|
||||||
|
|
@ -238,7 +242,7 @@ defmodule MvWeb.RoleLive.ShowTest do
|
||||||
permission_set_name: "own_data"
|
permission_set_name: "own_data"
|
||||||
})
|
})
|
||||||
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|
|> 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}")
|
{:ok, _view, html} = live(conn, "/admin/roles/#{system_role.id}")
|
||||||
|
|
||||||
|
|
@ -258,8 +262,9 @@ defmodule MvWeb.RoleLive.ShowTest do
|
||||||
|
|
||||||
describe "page title" do
|
describe "page title" do
|
||||||
setup %{conn: conn} do
|
setup %{conn: conn} do
|
||||||
{conn, _user, _admin_role} = create_admin_user(conn)
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
%{conn: conn}
|
{conn, _user, _admin_role} = create_admin_user(conn, system_actor)
|
||||||
|
%{conn: conn, actor: system_actor}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "sets correct page title", %{conn: conn} do
|
test "sets correct page title", %{conn: conn} do
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ defmodule MvWeb.RoleLiveTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create admin user with admin role
|
# Helper to create admin user with admin role
|
||||||
defp create_admin_user(conn) do
|
defp create_admin_user(conn, actor) do
|
||||||
# Create admin role
|
# Create admin role
|
||||||
admin_role =
|
admin_role =
|
||||||
case Authorization.list_roles() do
|
case Authorization.list_roles() do
|
||||||
|
|
@ -60,17 +60,17 @@ defmodule MvWeb.RoleLiveTest do
|
||||||
email: "admin#{System.unique_integer([:positive])}@mv.local",
|
email: "admin#{System.unique_integer([:positive])}@mv.local",
|
||||||
password: "testpassword123"
|
password: "testpassword123"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# Assign admin role using manage_relationship
|
# Assign admin role using manage_relationship
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
user
|
user
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
|> 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)
|
# 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
|
# Store user with role in session for LiveView
|
||||||
conn = conn_with_password_user(conn, user_with_role)
|
conn = conn_with_password_user(conn, user_with_role)
|
||||||
|
|
@ -78,14 +78,14 @@ defmodule MvWeb.RoleLiveTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create non-admin user
|
# Helper to create non-admin user
|
||||||
defp create_non_admin_user(conn) do
|
defp create_non_admin_user(conn, actor) do
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||||
email: "user#{System.unique_integer([:positive])}@mv.local",
|
email: "user#{System.unique_integer([:positive])}@mv.local",
|
||||||
password: "testpassword123"
|
password: "testpassword123"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
conn = conn_with_password_user(conn, user)
|
conn = conn_with_password_user(conn, user)
|
||||||
{conn, user}
|
{conn, user}
|
||||||
|
|
@ -93,8 +93,9 @@ defmodule MvWeb.RoleLiveTest do
|
||||||
|
|
||||||
describe "index page" do
|
describe "index page" do
|
||||||
setup %{conn: conn} do
|
setup %{conn: conn} do
|
||||||
{conn, user, _admin_role} = create_admin_user(conn)
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
%{conn: conn, user: user}
|
{conn, user, _admin_role} = create_admin_user(conn, system_actor)
|
||||||
|
%{conn: conn, actor: system_actor, user: user}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "mounts successfully", %{conn: conn} do
|
test "mounts successfully", %{conn: conn} do
|
||||||
|
|
@ -121,7 +122,7 @@ defmodule MvWeb.RoleLiveTest do
|
||||||
assert html =~ role.permission_set_name
|
assert html =~ role.permission_set_name
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows system role badge", %{conn: conn} do
|
test "shows system role badge", %{conn: conn, actor: actor} do
|
||||||
_system_role =
|
_system_role =
|
||||||
Role
|
Role
|
||||||
|> Ash.Changeset.for_create(:create_role, %{
|
|> Ash.Changeset.for_create(:create_role, %{
|
||||||
|
|
@ -129,14 +130,14 @@ defmodule MvWeb.RoleLiveTest do
|
||||||
permission_set_name: "own_data"
|
permission_set_name: "own_data"
|
||||||
})
|
})
|
||||||
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/admin/roles")
|
{:ok, _view, html} = live(conn, "/admin/roles")
|
||||||
|
|
||||||
assert html =~ "System Role" || html =~ "system"
|
assert html =~ "System Role" || html =~ "system"
|
||||||
end
|
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 =
|
system_role =
|
||||||
Role
|
Role
|
||||||
|> Ash.Changeset.for_create(:create_role, %{
|
|> Ash.Changeset.for_create(:create_role, %{
|
||||||
|
|
@ -144,7 +145,7 @@ defmodule MvWeb.RoleLiveTest do
|
||||||
permission_set_name: "own_data"
|
permission_set_name: "own_data"
|
||||||
})
|
})
|
||||||
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/admin/roles")
|
{:ok, view, _html} = live(conn, "/admin/roles")
|
||||||
|
|
||||||
|
|
@ -191,8 +192,9 @@ defmodule MvWeb.RoleLiveTest do
|
||||||
|
|
||||||
describe "show page" do
|
describe "show page" do
|
||||||
setup %{conn: conn} do
|
setup %{conn: conn} do
|
||||||
{conn, user, _admin_role} = create_admin_user(conn)
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
%{conn: conn, user: user}
|
{conn, user, _admin_role} = create_admin_user(conn, system_actor)
|
||||||
|
%{conn: conn, actor: system_actor, user: user}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "mounts with valid role ID", %{conn: conn} do
|
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)
|
assert match?({:error, {:redirect, %{to: "/admin/roles"}}}, result)
|
||||||
end
|
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 =
|
system_role =
|
||||||
Role
|
Role
|
||||||
|> Ash.Changeset.for_create(:create_role, %{
|
|> Ash.Changeset.for_create(:create_role, %{
|
||||||
|
|
@ -223,7 +225,7 @@ defmodule MvWeb.RoleLiveTest do
|
||||||
permission_set_name: "own_data"
|
permission_set_name: "own_data"
|
||||||
})
|
})
|
||||||
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|
|> 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}")
|
{:ok, _view, html} = live(conn, "/admin/roles/#{system_role.id}")
|
||||||
|
|
||||||
|
|
@ -233,8 +235,9 @@ defmodule MvWeb.RoleLiveTest do
|
||||||
|
|
||||||
describe "form - create" do
|
describe "form - create" do
|
||||||
setup %{conn: conn} do
|
setup %{conn: conn} do
|
||||||
{conn, user, _admin_role} = create_admin_user(conn)
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
%{conn: conn, user: user}
|
{conn, user, _admin_role} = create_admin_user(conn, system_actor)
|
||||||
|
%{conn: conn, actor: system_actor, user: user}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "mounts successfully", %{conn: conn} do
|
test "mounts successfully", %{conn: conn} do
|
||||||
|
|
@ -306,9 +309,10 @@ defmodule MvWeb.RoleLiveTest do
|
||||||
|
|
||||||
describe "form - edit" do
|
describe "form - edit" do
|
||||||
setup %{conn: conn} 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()
|
role = create_role()
|
||||||
%{conn: conn, user: user, role: role}
|
%{conn: conn, actor: system_actor, user: user, role: role}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "mounts with valid role ID", %{conn: conn, role: role} do
|
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"
|
assert updated_role.name == "Updated Role Name"
|
||||||
end
|
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 =
|
system_role =
|
||||||
Role
|
Role
|
||||||
|> Ash.Changeset.for_create(:create_role, %{
|
|> Ash.Changeset.for_create(:create_role, %{
|
||||||
|
|
@ -355,7 +359,7 @@ defmodule MvWeb.RoleLiveTest do
|
||||||
permission_set_name: "own_data"
|
permission_set_name: "own_data"
|
||||||
})
|
})
|
||||||
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|
|> 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")
|
{: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
|
describe "delete functionality" do
|
||||||
setup %{conn: conn} do
|
setup %{conn: conn} do
|
||||||
{conn, user, _admin_role} = create_admin_user(conn)
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
%{conn: conn, user: user}
|
{conn, user, _admin_role} = create_admin_user(conn, system_actor)
|
||||||
|
%{conn: conn, actor: system_actor, user: user}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "deletes non-system role", %{conn: conn} do
|
test "deletes non-system role", %{conn: conn} do
|
||||||
|
|
@ -400,7 +405,7 @@ defmodule MvWeb.RoleLiveTest do
|
||||||
Authorization.get_role(role.id)
|
Authorization.get_role(role.id)
|
||||||
end
|
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 =
|
system_role =
|
||||||
Role
|
Role
|
||||||
|> Ash.Changeset.for_create(:create_role, %{
|
|> Ash.Changeset.for_create(:create_role, %{
|
||||||
|
|
@ -408,7 +413,7 @@ defmodule MvWeb.RoleLiveTest do
|
||||||
permission_set_name: "own_data"
|
permission_set_name: "own_data"
|
||||||
})
|
})
|
||||||
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
|
|
||||||
{:ok, view, html} = live(conn, "/admin/roles")
|
{:ok, view, html} = live(conn, "/admin/roles")
|
||||||
|
|
||||||
|
|
@ -428,8 +433,13 @@ defmodule MvWeb.RoleLiveTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "authorization" do
|
describe "authorization" do
|
||||||
test "only admin can access /admin/roles", %{conn: conn} do
|
setup do
|
||||||
{conn, _user} = create_non_admin_user(conn)
|
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
|
# Non-admin should be redirected or see error
|
||||||
# Note: Authorization is checked via can_access_page? which returns false
|
# 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"
|
assert html =~ "Listing Roles" || html =~ "Roles"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "admin can access /admin/roles", %{conn: conn} do
|
test "admin can access /admin/roles", %{conn: conn, actor: actor} do
|
||||||
{conn, _user, _admin_role} = create_admin_user(conn)
|
{conn, _user, _admin_role} = create_admin_user(conn, actor)
|
||||||
|
|
||||||
{:ok, _view, _html} = live(conn, "/admin/roles")
|
{:ok, _view, _html} = live(conn, "/admin/roles")
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,8 @@ defmodule MvWeb.UserLive.ShowTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "displays linked member when present", %{conn: conn} do
|
test "displays linked member when present", %{conn: conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Create member
|
# Create member
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Member
|
Member
|
||||||
|
|
@ -72,7 +74,7 @@ defmodule MvWeb.UserLive.ShowTest do
|
||||||
last_name: "Smith",
|
last_name: "Smith",
|
||||||
email: "alice@example.com"
|
email: "alice@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Create user and link to member
|
# Create user and link to member
|
||||||
user = create_test_user(%{email: "user@example.com"})
|
user = create_test_user(%{email: "user@example.com"})
|
||||||
|
|
@ -81,7 +83,7 @@ defmodule MvWeb.UserLive.ShowTest do
|
||||||
user
|
user
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|> Ash.Changeset.manage_relationship(:member, member, type: :append_and_remove)
|
|> Ash.Changeset.manage_relationship(:member, member, type: :append_and_remove)
|
||||||
|> Ash.update()
|
|> Ash.update(actor: system_actor)
|
||||||
|
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, _view, html} = live(conn, ~p"/users/#{user.id}")
|
{:ok, _view, html} = live(conn, ~p"/users/#{user.id}")
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
|
||||||
|
|
||||||
describe "error handling - flash messages" do
|
describe "error handling - flash messages" do
|
||||||
test "shows flash message when member creation fails with validation error", %{conn: conn} 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
|
# Create a member with the same email to trigger uniqueness error
|
||||||
{:ok, _existing_member} =
|
{:ok, _existing_member} =
|
||||||
Member
|
Member
|
||||||
|
|
@ -20,7 +22,7 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
email: "duplicate@example.com"
|
email: "duplicate@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members/new")
|
{:ok, view, _html} = live(conn, "/members/new")
|
||||||
|
|
@ -73,6 +75,8 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows flash message when member update fails", %{conn: conn} do
|
test "shows flash message when member update fails", %{conn: conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Create a member to edit
|
# Create a member to edit
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Member
|
Member
|
||||||
|
|
@ -81,7 +85,7 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
email: "original@example.com"
|
email: "original@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Create another member with different email
|
# Create another member with different email
|
||||||
{:ok, _other_member} =
|
{:ok, _other_member} =
|
||||||
|
|
@ -91,7 +95,7 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
email: "other@example.com"
|
email: "other@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
|
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
# Helper to create a membership fee type
|
# 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 = %{
|
default_attrs = %{
|
||||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||||
amount: Decimal.new("50.00"),
|
amount: Decimal.new("50.00"),
|
||||||
|
|
@ -23,11 +24,12 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|
||||||
|
|
||||||
MembershipFeeType
|
MembershipFeeType
|
||||||
|> Ash.Changeset.for_create(:create, attrs)
|
|> Ash.Changeset.for_create(:create, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: admin_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a member
|
# 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 = %{
|
default_attrs = %{
|
||||||
first_name: "Test",
|
first_name: "Test",
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
|
|
@ -38,7 +40,7 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|
||||||
|
|
||||||
Member
|
Member
|
||||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: admin_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "membership fee type dropdown" do
|
describe "membership fee type dropdown" do
|
||||||
|
|
@ -50,9 +52,9 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|
||||||
html =~ "Beitragsart"
|
html =~ "Beitragsart"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows available types", %{conn: conn} do
|
test "shows available types", %{conn: conn, current_user: admin_user} do
|
||||||
_fee_type1 = create_fee_type(%{name: "Type 1", interval: :yearly})
|
_fee_type1 = create_fee_type(%{name: "Type 1", interval: :yearly}, admin_user)
|
||||||
_fee_type2 = create_fee_type(%{name: "Type 2", interval: :yearly})
|
_fee_type2 = create_fee_type(%{name: "Type 2", interval: :yearly}, admin_user)
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/members/new")
|
{:ok, _view, html} = live(conn, "/members/new")
|
||||||
|
|
||||||
|
|
@ -60,11 +62,14 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|
||||||
assert html =~ "Type 2"
|
assert html =~ "Type 2"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "filters to same interval types if member has type", %{conn: conn} do
|
test "filters to same interval types if member has type", %{
|
||||||
yearly_type = create_fee_type(%{name: "Yearly Type", interval: :yearly})
|
conn: conn,
|
||||||
_monthly_type = create_fee_type(%{name: "Monthly Type", interval: :monthly})
|
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")
|
{:ok, _view, html} = live(conn, "/members/#{member.id}/edit")
|
||||||
|
|
||||||
|
|
@ -73,11 +78,11 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|
||||||
refute html =~ "Monthly Type"
|
refute html =~ "Monthly Type"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows warning if different interval selected", %{conn: conn} do
|
test "shows warning if different interval selected", %{conn: conn, current_user: admin_user} do
|
||||||
yearly_type = create_fee_type(%{name: "Yearly Type", interval: :yearly})
|
yearly_type = create_fee_type(%{name: "Yearly Type", interval: :yearly}, admin_user)
|
||||||
monthly_type = create_fee_type(%{name: "Monthly Type", interval: :monthly})
|
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")
|
{:ok, _view, html} = live(conn, "/members/#{member.id}/edit")
|
||||||
|
|
||||||
|
|
@ -88,11 +93,11 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|
||||||
assert html =~ yearly_type.id
|
assert html =~ yearly_type.id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "warning cleared if same interval selected", %{conn: conn} do
|
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})
|
yearly_type1 = create_fee_type(%{name: "Yearly Type 1", interval: :yearly}, admin_user)
|
||||||
yearly_type2 = create_fee_type(%{name: "Yearly Type 2", interval: :yearly})
|
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")
|
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
|
||||||
|
|
||||||
|
|
@ -105,8 +110,8 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|
||||||
refute html =~ "Warning" || html =~ "Warnung"
|
refute html =~ "Warning" || html =~ "Warnung"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "form saves with selected membership fee type", %{conn: conn} do
|
test "form saves with selected membership fee type", %{conn: conn, current_user: admin_user} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, admin_user)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/members/new")
|
{:ok, view, _html} = live(conn, "/members/new")
|
||||||
|
|
||||||
|
|
@ -122,18 +127,18 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|
||||||
|> form("#member-form", form_data)
|
|> form("#member-form", form_data)
|
||||||
|> render_submit()
|
|> render_submit()
|
||||||
|
|
||||||
# Verify member was created with fee type
|
# Verify member was created with fee type - use admin_user to test permissions
|
||||||
member =
|
member =
|
||||||
Member
|
Member
|
||||||
|> Ash.Query.filter(email == ^form_data["member[email]"])
|
|> 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
|
assert member.membership_fee_type_id == fee_type.id
|
||||||
end
|
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
|
# 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()
|
{:ok, settings} = Mv.Membership.get_settings()
|
||||||
|
|
||||||
|
|
@ -141,7 +146,7 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|
||||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
||||||
default_membership_fee_type_id: fee_type.id
|
default_membership_fee_type_id: fee_type.id
|
||||||
})
|
})
|
||||||
|> Ash.update!()
|
|> Ash.update!(actor: admin_user)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/members/new")
|
{:ok, view, _html} = live(conn, "/members/new")
|
||||||
|
|
||||||
|
|
@ -156,7 +161,7 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|
||||||
conn: conn,
|
conn: conn,
|
||||||
current_user: admin_user
|
current_user: admin_user
|
||||||
} do
|
} do
|
||||||
# Create custom field
|
# Create custom field - use admin_user to test permissions
|
||||||
custom_field =
|
custom_field =
|
||||||
Mv.Membership.CustomField
|
Mv.Membership.CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
|
@ -164,11 +169,11 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
required: false
|
required: false
|
||||||
})
|
})
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: admin_user)
|
||||||
|
|
||||||
# Create two fee types with same interval
|
# Create two fee types with same interval
|
||||||
fee_type1 = create_fee_type(%{name: "Type 1", interval: :yearly})
|
fee_type1 = create_fee_type(%{name: "Type 1", interval: :yearly}, admin_user)
|
||||||
fee_type2 = create_fee_type(%{name: "Type 2", interval: :yearly})
|
fee_type2 = create_fee_type(%{name: "Type 2", interval: :yearly}, admin_user)
|
||||||
|
|
||||||
# Create member with fee type 1 and custom field value
|
# Create member with fee type 1 and custom field value
|
||||||
member =
|
member =
|
||||||
|
|
@ -203,7 +208,7 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "union/typed values roundtrip correctly", %{conn: conn, current_user: admin_user} do
|
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 =
|
custom_field =
|
||||||
Mv.Membership.CustomField
|
Mv.Membership.CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
|
@ -211,9 +216,9 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|
||||||
value_type: :date,
|
value_type: :date,
|
||||||
required: false
|
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
|
# Create member with date custom field value
|
||||||
member =
|
member =
|
||||||
|
|
@ -250,7 +255,7 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "removing custom field values works correctly", %{conn: conn, current_user: admin_user} do
|
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 =
|
custom_field =
|
||||||
Mv.Membership.CustomField
|
Mv.Membership.CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
|
@ -258,9 +263,9 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
required: false
|
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
|
# Create member with custom field value
|
||||||
member =
|
member =
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
||||||
|
|
||||||
# Helper to create a membership fee type
|
# Helper to create a membership fee type
|
||||||
defp create_fee_type(attrs) do
|
defp create_fee_type(attrs) do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||||
amount: Decimal.new("50.00"),
|
amount: Decimal.new("50.00"),
|
||||||
|
|
@ -23,11 +25,13 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
||||||
|
|
||||||
MembershipFeeType
|
MembershipFeeType
|
||||||
|> Ash.Changeset.for_create(:create, attrs)
|
|> Ash.Changeset.for_create(:create, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: system_actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a member
|
# Helper to create a member
|
||||||
defp create_member(attrs) do
|
defp create_member(attrs) do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
first_name: "Test",
|
first_name: "Test",
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
|
|
@ -38,13 +42,15 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
||||||
|
|
||||||
Member
|
Member
|
||||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: system_actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a cycle
|
# Helper to create a cycle
|
||||||
# Note: Does not delete existing cycles - tests should manage their own test data
|
# 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
|
# If cleanup is needed, it should be done in setup or explicitly in the test
|
||||||
defp create_cycle(member, fee_type, attrs) do
|
defp create_cycle(member, fee_type, attrs) do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
cycle_start: ~D[2023-01-01],
|
cycle_start: ~D[2023-01-01],
|
||||||
amount: Decimal.new("50.00"),
|
amount: Decimal.new("50.00"),
|
||||||
|
|
@ -57,7 +63,7 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
||||||
|
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|> Ash.Changeset.for_create(:create, attrs)
|
|> Ash.Changeset.for_create(:create, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: system_actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "load_cycles_for_members/2" do
|
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])
|
|> Ash.Query.filter(id in [^member1.id, ^member2.id])
|
||||||
|> MembershipFeeStatus.load_cycles_for_members()
|
|> 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
|
assert length(members) == 2
|
||||||
|
|
||||||
|
|
@ -94,19 +101,21 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
||||||
# Create member without fee type to avoid auto-generation
|
# Create member without fee type to avoid auto-generation
|
||||||
member = create_member(%{})
|
member = create_member(%{})
|
||||||
|
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Assign fee type
|
# Assign fee type
|
||||||
member =
|
member =
|
||||||
member
|
member
|
||||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
|> 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
|
# Delete any auto-generated cycles
|
||||||
cycles =
|
cycles =
|
||||||
Mv.MembershipFees.MembershipFeeCycle
|
Mv.MembershipFees.MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member.id)
|
|> 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
|
# Create cycles with dates that ensure 2023 is last completed
|
||||||
# Use a fixed "today" date in 2024 to make 2023 the 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
|
# Create member without fee type to avoid auto-generation
|
||||||
member = create_member(%{})
|
member = create_member(%{})
|
||||||
|
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Assign fee type
|
# Assign fee type
|
||||||
member =
|
member =
|
||||||
member
|
member
|
||||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
|> 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
|
# Delete any auto-generated cycles
|
||||||
cycles =
|
cycles =
|
||||||
Mv.MembershipFees.MembershipFeeCycle
|
Mv.MembershipFees.MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member.id)
|
|> 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
|
# Create cycles - use current year for current cycle
|
||||||
today = Date.utc_today()
|
today = Date.utc_today()
|
||||||
|
|
@ -176,19 +187,21 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
||||||
# Create member without fee type to avoid auto-generation
|
# Create member without fee type to avoid auto-generation
|
||||||
member = create_member(%{})
|
member = create_member(%{})
|
||||||
|
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Assign fee type
|
# Assign fee type
|
||||||
member =
|
member =
|
||||||
member
|
member
|
||||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
|> 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
|
# Delete any auto-generated cycles
|
||||||
cycles =
|
cycles =
|
||||||
Mv.MembershipFees.MembershipFeeCycle
|
Mv.MembershipFees.MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member.id)
|
|> 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)
|
# Load cycles and fee type first (will be empty)
|
||||||
member =
|
member =
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do
|
||||||
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Create test member
|
# Create test member
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Member
|
Member
|
||||||
|
|
@ -22,7 +24,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do
|
||||||
last_name: "Anderson",
|
last_name: "Anderson",
|
||||||
email: "alice@example.com"
|
email: "alice@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Create custom field with show_in_overview: true
|
# Create custom field with show_in_overview: true
|
||||||
{:ok, field} =
|
{:ok, field} =
|
||||||
|
|
@ -32,7 +34,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
show_in_overview: true
|
show_in_overview: true
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Create custom field value
|
# Create custom field value
|
||||||
{:ok, _cfv} =
|
{:ok, _cfv} =
|
||||||
|
|
@ -42,7 +44,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do
|
||||||
custom_field_id: field.id,
|
custom_field_id: field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "A001"}
|
value: %{"_union_type" => "string", "_union_value" => "A001"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
%{member: member, field: field}
|
%{member: member, field: field}
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
|
||||||
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Create test members
|
# Create test members
|
||||||
{:ok, member1} =
|
{:ok, member1} =
|
||||||
Member
|
Member
|
||||||
|
|
@ -25,7 +27,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
|
||||||
last_name: "Anderson",
|
last_name: "Anderson",
|
||||||
email: "alice@example.com"
|
email: "alice@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, member2} =
|
{:ok, member2} =
|
||||||
Member
|
Member
|
||||||
|
|
@ -34,7 +36,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
|
||||||
last_name: "Brown",
|
last_name: "Brown",
|
||||||
email: "bob@example.com"
|
email: "bob@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Create custom fields
|
# Create custom fields
|
||||||
{:ok, field_show_string} =
|
{:ok, field_show_string} =
|
||||||
|
|
@ -44,7 +46,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
show_in_overview: true
|
show_in_overview: true
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, field_hide} =
|
{:ok, field_hide} =
|
||||||
CustomField
|
CustomField
|
||||||
|
|
@ -53,7 +55,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
show_in_overview: false
|
show_in_overview: false
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, field_show_integer} =
|
{:ok, field_show_integer} =
|
||||||
CustomField
|
CustomField
|
||||||
|
|
@ -62,7 +64,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
|
||||||
value_type: :integer,
|
value_type: :integer,
|
||||||
show_in_overview: true
|
show_in_overview: true
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, field_show_boolean} =
|
{:ok, field_show_boolean} =
|
||||||
CustomField
|
CustomField
|
||||||
|
|
@ -71,7 +73,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
|
||||||
value_type: :boolean,
|
value_type: :boolean,
|
||||||
show_in_overview: true
|
show_in_overview: true
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, field_show_date} =
|
{:ok, field_show_date} =
|
||||||
CustomField
|
CustomField
|
||||||
|
|
@ -80,7 +82,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
|
||||||
value_type: :date,
|
value_type: :date,
|
||||||
show_in_overview: true
|
show_in_overview: true
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, field_show_email} =
|
{:ok, field_show_email} =
|
||||||
CustomField
|
CustomField
|
||||||
|
|
@ -89,7 +91,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
|
||||||
value_type: :email,
|
value_type: :email,
|
||||||
show_in_overview: true
|
show_in_overview: true
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Create custom field values for member1
|
# Create custom field values for member1
|
||||||
{:ok, _cfv1} =
|
{:ok, _cfv1} =
|
||||||
|
|
@ -99,7 +101,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
|
||||||
custom_field_id: field_show_string.id,
|
custom_field_id: field_show_string.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "+49123456789"}
|
value: %{"_union_type" => "string", "_union_value" => "+49123456789"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, _cfv2} =
|
{:ok, _cfv2} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|
|
@ -108,7 +110,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
|
||||||
custom_field_id: field_show_integer.id,
|
custom_field_id: field_show_integer.id,
|
||||||
value: %{"_union_type" => "integer", "_union_value" => 12_345}
|
value: %{"_union_type" => "integer", "_union_value" => 12_345}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, _cfv3} =
|
{:ok, _cfv3} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|
|
@ -117,7 +119,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
|
||||||
custom_field_id: field_show_boolean.id,
|
custom_field_id: field_show_boolean.id,
|
||||||
value: %{"_union_type" => "boolean", "_union_value" => true}
|
value: %{"_union_type" => "boolean", "_union_value" => true}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, _cfv4} =
|
{:ok, _cfv4} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|
|
@ -126,7 +128,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
|
||||||
custom_field_id: field_show_date.id,
|
custom_field_id: field_show_date.id,
|
||||||
value: %{"_union_type" => "date", "_union_value" => ~D[1990-05-15]}
|
value: %{"_union_type" => "date", "_union_value" => ~D[1990-05-15]}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, _cfv5} =
|
{:ok, _cfv5} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|
|
@ -135,7 +137,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
|
||||||
custom_field_id: field_show_email.id,
|
custom_field_id: field_show_email.id,
|
||||||
value: %{"_union_type" => "email", "_union_value" => "alice.private@example.com"}
|
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)
|
# Create hidden custom field value (should not be displayed)
|
||||||
{:ok, _cfv_hidden} =
|
{:ok, _cfv_hidden} =
|
||||||
|
|
@ -145,7 +147,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
|
||||||
custom_field_id: field_hide.id,
|
custom_field_id: field_hide.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "Internal note"}
|
value: %{"_union_type" => "string", "_union_value" => "Internal note"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
member1: member1,
|
member1: member1,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
|
||||||
alias Mv.Membership.{CustomField, Member}
|
alias Mv.Membership.{CustomField, Member}
|
||||||
|
|
||||||
test "displays custom field column even when no members have values", %{conn: conn} do
|
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
|
# Create test members without custom field values
|
||||||
{:ok, _member1} =
|
{:ok, _member1} =
|
||||||
Member
|
Member
|
||||||
|
|
@ -21,7 +23,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
|
||||||
last_name: "Anderson",
|
last_name: "Anderson",
|
||||||
email: "alice@example.com"
|
email: "alice@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, _member2} =
|
{:ok, _member2} =
|
||||||
Member
|
Member
|
||||||
|
|
@ -30,7 +32,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
|
||||||
last_name: "Brown",
|
last_name: "Brown",
|
||||||
email: "bob@example.com"
|
email: "bob@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Create custom field with show_in_overview: true but no values
|
# Create custom field with show_in_overview: true but no values
|
||||||
{:ok, field} =
|
{:ok, field} =
|
||||||
|
|
@ -40,7 +42,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
show_in_overview: true
|
show_in_overview: true
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
{:ok, _view, html} = live(conn, "/members")
|
||||||
|
|
@ -50,6 +52,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "displays very long custom field values correctly", %{conn: conn} do
|
test "displays very long custom field values correctly", %{conn: conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Create test member
|
# Create test member
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Member
|
Member
|
||||||
|
|
@ -58,7 +62,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
|
||||||
last_name: "Anderson",
|
last_name: "Anderson",
|
||||||
email: "alice@example.com"
|
email: "alice@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Create custom field
|
# Create custom field
|
||||||
{:ok, field} =
|
{:ok, field} =
|
||||||
|
|
@ -68,7 +72,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
show_in_overview: true
|
show_in_overview: true
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Create very long value (but within limits)
|
# Create very long value (but within limits)
|
||||||
long_value = String.duplicate("A", 500)
|
long_value = String.duplicate("A", 500)
|
||||||
|
|
@ -80,7 +84,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
|
||||||
custom_field_id: field.id,
|
custom_field_id: field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => long_value}
|
value: %{"_union_type" => "string", "_union_value" => long_value}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
{:ok, _view, html} = live(conn, "/members")
|
||||||
|
|
@ -91,6 +95,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles multiple custom fields with show_in_overview correctly", %{conn: conn} do
|
test "handles multiple custom fields with show_in_overview correctly", %{conn: conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Create test member
|
# Create test member
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Member
|
Member
|
||||||
|
|
@ -99,7 +105,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
|
||||||
last_name: "Anderson",
|
last_name: "Anderson",
|
||||||
email: "alice@example.com"
|
email: "alice@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Create multiple custom fields with show_in_overview: true
|
# Create multiple custom fields with show_in_overview: true
|
||||||
{:ok, field1} =
|
{:ok, field1} =
|
||||||
|
|
@ -109,7 +115,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
show_in_overview: true
|
show_in_overview: true
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, field2} =
|
{:ok, field2} =
|
||||||
CustomField
|
CustomField
|
||||||
|
|
@ -118,7 +124,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
show_in_overview: true
|
show_in_overview: true
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, field3} =
|
{:ok, field3} =
|
||||||
CustomField
|
CustomField
|
||||||
|
|
@ -127,7 +133,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
show_in_overview: true
|
show_in_overview: true
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Create values for all fields
|
# Create values for all fields
|
||||||
{:ok, _cfv1} =
|
{:ok, _cfv1} =
|
||||||
|
|
@ -137,7 +143,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
|
||||||
custom_field_id: field1.id,
|
custom_field_id: field1.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "Value1"}
|
value: %{"_union_type" => "string", "_union_value" => "Value1"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, _cfv2} =
|
{:ok, _cfv2} =
|
||||||
Mv.Membership.CustomFieldValue
|
Mv.Membership.CustomFieldValue
|
||||||
|
|
@ -146,7 +152,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
|
||||||
custom_field_id: field2.id,
|
custom_field_id: field2.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "Value2"}
|
value: %{"_union_type" => "string", "_union_value" => "Value2"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, _cfv3} =
|
{:ok, _cfv3} =
|
||||||
Mv.Membership.CustomFieldValue
|
Mv.Membership.CustomFieldValue
|
||||||
|
|
@ -155,7 +161,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
|
||||||
custom_field_id: field3.id,
|
custom_field_id: field3.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "Value3"}
|
value: %{"_union_type" => "string", "_union_value" => "Value3"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
{:ok, _view, html} = live(conn, "/members")
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Create test members
|
# Create test members
|
||||||
{:ok, member1} =
|
{:ok, member1} =
|
||||||
Member
|
Member
|
||||||
|
|
@ -24,7 +26,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
last_name: "Anderson",
|
last_name: "Anderson",
|
||||||
email: "alice@example.com"
|
email: "alice@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, member2} =
|
{:ok, member2} =
|
||||||
Member
|
Member
|
||||||
|
|
@ -33,7 +35,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
last_name: "Brown",
|
last_name: "Brown",
|
||||||
email: "bob@example.com"
|
email: "bob@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, member3} =
|
{:ok, member3} =
|
||||||
Member
|
Member
|
||||||
|
|
@ -42,7 +44,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
last_name: "Clark",
|
last_name: "Clark",
|
||||||
email: "charlie@example.com"
|
email: "charlie@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Create custom field with show_in_overview: true
|
# Create custom field with show_in_overview: true
|
||||||
{:ok, field_string} =
|
{:ok, field_string} =
|
||||||
|
|
@ -52,7 +54,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
show_in_overview: true
|
show_in_overview: true
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, field_integer} =
|
{:ok, field_integer} =
|
||||||
CustomField
|
CustomField
|
||||||
|
|
@ -61,7 +63,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
value_type: :integer,
|
value_type: :integer,
|
||||||
show_in_overview: true
|
show_in_overview: true
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Create custom field values
|
# Create custom field values
|
||||||
{:ok, _cfv1} =
|
{:ok, _cfv1} =
|
||||||
|
|
@ -71,7 +73,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
custom_field_id: field_string.id,
|
custom_field_id: field_string.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "A001"}
|
value: %{"_union_type" => "string", "_union_value" => "A001"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, _cfv2} =
|
{:ok, _cfv2} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|
|
@ -80,7 +82,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
custom_field_id: field_string.id,
|
custom_field_id: field_string.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "C003"}
|
value: %{"_union_type" => "string", "_union_value" => "C003"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, _cfv3} =
|
{:ok, _cfv3} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|
|
@ -89,7 +91,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
custom_field_id: field_string.id,
|
custom_field_id: field_string.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "B002"}
|
value: %{"_union_type" => "string", "_union_value" => "B002"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, _cfv4} =
|
{:ok, _cfv4} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|
|
@ -98,7 +100,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
custom_field_id: field_integer.id,
|
custom_field_id: field_integer.id,
|
||||||
value: %{"_union_type" => "integer", "_union_value" => 10}
|
value: %{"_union_type" => "integer", "_union_value" => 10}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, _cfv5} =
|
{:ok, _cfv5} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|
|
@ -107,7 +109,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
custom_field_id: field_integer.id,
|
custom_field_id: field_integer.id,
|
||||||
value: %{"_union_type" => "integer", "_union_value" => 30}
|
value: %{"_union_type" => "integer", "_union_value" => 30}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, _cfv6} =
|
{:ok, _cfv6} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|
|
@ -116,7 +118,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
custom_field_id: field_integer.id,
|
custom_field_id: field_integer.id,
|
||||||
value: %{"_union_type" => "integer", "_union_value" => 20}
|
value: %{"_union_type" => "integer", "_union_value" => 20}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
member1: member1,
|
member1: member1,
|
||||||
|
|
@ -236,6 +238,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "NULL values and empty strings are always sorted last (ASC)", %{conn: conn} do
|
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
|
# Create additional members with NULL and empty string values
|
||||||
{:ok, member_with_value} =
|
{:ok, member_with_value} =
|
||||||
Member
|
Member
|
||||||
|
|
@ -244,7 +248,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
last_name: "Test",
|
last_name: "Test",
|
||||||
email: "withvalue@example.com"
|
email: "withvalue@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, member_with_empty} =
|
{:ok, member_with_empty} =
|
||||||
Member
|
Member
|
||||||
|
|
@ -253,7 +257,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
last_name: "Test",
|
last_name: "Test",
|
||||||
email: "withempty@example.com"
|
email: "withempty@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, member_with_null} =
|
{:ok, member_with_null} =
|
||||||
Member
|
Member
|
||||||
|
|
@ -262,7 +266,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
last_name: "Test",
|
last_name: "Test",
|
||||||
email: "withnull@example.com"
|
email: "withnull@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, member_with_another_value} =
|
{:ok, member_with_another_value} =
|
||||||
Member
|
Member
|
||||||
|
|
@ -271,7 +275,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
last_name: "Test",
|
last_name: "Test",
|
||||||
email: "another@example.com"
|
email: "another@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Create custom field
|
# Create custom field
|
||||||
{:ok, field} =
|
{:ok, field} =
|
||||||
|
|
@ -281,7 +285,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
show_in_overview: true
|
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
|
# Create values: one with actual value, one with empty string, one with NULL (no value), another with value
|
||||||
{:ok, _cfv1} =
|
{:ok, _cfv1} =
|
||||||
|
|
@ -291,7 +295,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
custom_field_id: field.id,
|
custom_field_id: field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "Zebra"}
|
value: %{"_union_type" => "string", "_union_value" => "Zebra"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, _cfv2} =
|
{:ok, _cfv2} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|
|
@ -300,7 +304,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
custom_field_id: field.id,
|
custom_field_id: field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => ""}
|
value: %{"_union_type" => "string", "_union_value" => ""}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# member_with_null has no custom field value (NULL)
|
# member_with_null has no custom field value (NULL)
|
||||||
|
|
||||||
|
|
@ -311,7 +315,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
custom_field_id: field.id,
|
custom_field_id: field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "Apple"}
|
value: %{"_union_type" => "string", "_union_value" => "Apple"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
|
|
||||||
|
|
@ -347,6 +351,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "NULL values and empty strings are always sorted last (DESC)", %{conn: conn} do
|
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
|
# Create additional members with NULL and empty string values
|
||||||
{:ok, member_with_value} =
|
{:ok, member_with_value} =
|
||||||
Member
|
Member
|
||||||
|
|
@ -355,7 +361,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
last_name: "Test",
|
last_name: "Test",
|
||||||
email: "withvalue@example.com"
|
email: "withvalue@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, member_with_empty} =
|
{:ok, member_with_empty} =
|
||||||
Member
|
Member
|
||||||
|
|
@ -364,7 +370,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
last_name: "Test",
|
last_name: "Test",
|
||||||
email: "withempty@example.com"
|
email: "withempty@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, member_with_null} =
|
{:ok, member_with_null} =
|
||||||
Member
|
Member
|
||||||
|
|
@ -373,7 +379,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
last_name: "Test",
|
last_name: "Test",
|
||||||
email: "withnull@example.com"
|
email: "withnull@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, member_with_another_value} =
|
{:ok, member_with_another_value} =
|
||||||
Member
|
Member
|
||||||
|
|
@ -382,7 +388,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
last_name: "Test",
|
last_name: "Test",
|
||||||
email: "another@example.com"
|
email: "another@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Create custom field
|
# Create custom field
|
||||||
{:ok, field} =
|
{:ok, field} =
|
||||||
|
|
@ -392,7 +398,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
show_in_overview: true
|
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
|
# Create values: one with actual value, one with empty string, one with NULL (no value), another with value
|
||||||
{:ok, _cfv1} =
|
{:ok, _cfv1} =
|
||||||
|
|
@ -402,7 +408,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
custom_field_id: field.id,
|
custom_field_id: field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "Apple"}
|
value: %{"_union_type" => "string", "_union_value" => "Apple"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, _cfv2} =
|
{:ok, _cfv2} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|
|
@ -411,7 +417,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
custom_field_id: field.id,
|
custom_field_id: field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => ""}
|
value: %{"_union_type" => "string", "_union_value" => ""}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# member_with_null has no custom field value (NULL)
|
# member_with_null has no custom field value (NULL)
|
||||||
|
|
||||||
|
|
@ -422,7 +428,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
custom_field_id: field.id,
|
custom_field_id: field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "Zebra"}
|
value: %{"_union_type" => "string", "_union_value" => "Zebra"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||||
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Create test members
|
# Create test members
|
||||||
{:ok, member1} =
|
{:ok, member1} =
|
||||||
Member
|
Member
|
||||||
|
|
@ -29,7 +31,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||||
street: "Main St",
|
street: "Main St",
|
||||||
city: "Berlin"
|
city: "Berlin"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, member2} =
|
{:ok, member2} =
|
||||||
Member
|
Member
|
||||||
|
|
@ -40,7 +42,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||||
street: "Second St",
|
street: "Second St",
|
||||||
city: "Hamburg"
|
city: "Hamburg"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Create custom field
|
# Create custom field
|
||||||
{:ok, custom_field} =
|
{:ok, custom_field} =
|
||||||
|
|
@ -50,7 +52,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
show_in_overview: true
|
show_in_overview: true
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Create custom field values
|
# Create custom field values
|
||||||
{:ok, _cfv1} =
|
{:ok, _cfv1} =
|
||||||
|
|
@ -60,7 +62,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||||
custom_field_id: custom_field.id,
|
custom_field_id: custom_field.id,
|
||||||
value: "M001"
|
value: "M001"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, _cfv2} =
|
{:ok, _cfv2} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|
|
@ -69,7 +71,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||||
custom_field_id: custom_field.id,
|
custom_field_id: custom_field.id,
|
||||||
value: "M002"
|
value: "M002"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
member1: member1,
|
member1: member1,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
|
||||||
alias Mv.Membership.Member
|
alias Mv.Membership.Member
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
{:ok, member1} =
|
{:ok, member1} =
|
||||||
Member
|
Member
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|> Ash.Changeset.for_create(:create_member, %{
|
||||||
|
|
@ -18,7 +20,7 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
|
||||||
city: "Berlin",
|
city: "Berlin",
|
||||||
join_date: ~D[2020-01-15]
|
join_date: ~D[2020-01-15]
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, member2} =
|
{:ok, member2} =
|
||||||
Member
|
Member
|
||||||
|
|
@ -27,7 +29,7 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
|
||||||
last_name: "Brown",
|
last_name: "Brown",
|
||||||
email: "bob@example.com"
|
email: "bob@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
member1: member1,
|
member1: member1,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
|
||||||
|
|
||||||
# Helper to create a membership fee type
|
# Helper to create a membership fee type
|
||||||
defp create_fee_type(attrs) do
|
defp create_fee_type(attrs) do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||||
amount: Decimal.new("50.00"),
|
amount: Decimal.new("50.00"),
|
||||||
|
|
@ -24,11 +26,13 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
|
||||||
|
|
||||||
MembershipFeeType
|
MembershipFeeType
|
||||||
|> Ash.Changeset.for_create(:create, attrs)
|
|> Ash.Changeset.for_create(:create, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: system_actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a member
|
# Helper to create a member
|
||||||
defp create_member(attrs) do
|
defp create_member(attrs) do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
first_name: "Test",
|
first_name: "Test",
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
|
|
@ -39,18 +43,20 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
|
||||||
|
|
||||||
Member
|
Member
|
||||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: system_actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a cycle
|
# Helper to create a cycle
|
||||||
defp create_cycle(member, fee_type, attrs) do
|
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
|
# Delete any auto-generated cycles first to avoid conflicts
|
||||||
existing_cycles =
|
existing_cycles =
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member.id)
|
|> 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 = %{
|
default_attrs = %{
|
||||||
cycle_start: ~D[2023-01-01],
|
cycle_start: ~D[2023-01-01],
|
||||||
|
|
@ -64,7 +70,7 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
|
||||||
|
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|> Ash.Changeset.for_create(:create, attrs)
|
|> Ash.Changeset.for_create(:create, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: system_actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "status column display" do
|
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})
|
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})
|
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
|
# Verify cycles exist in database
|
||||||
cycles1 =
|
cycles1 =
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member1.id)
|
|> Ash.Query.filter(member_id == ^member1.id)
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
cycles2 =
|
cycles2 =
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member2.id)
|
|> Ash.Query.filter(member_id == ^member2.id)
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
refute Enum.empty?(cycles1)
|
refute Enum.empty?(cycles1)
|
||||||
refute Enum.empty?(cycles2)
|
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})
|
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})
|
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
|
# Verify cycles exist in database
|
||||||
cycles1 =
|
cycles1 =
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member1.id)
|
|> Ash.Query.filter(member_id == ^member1.id)
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
cycles2 =
|
cycles2 =
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member2.id)
|
|> Ash.Query.filter(member_id == ^member2.id)
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
refute Enum.empty?(cycles1)
|
refute Enum.empty?(cycles1)
|
||||||
refute Enum.empty?(cycles2)
|
refute Enum.empty?(cycles2)
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
|
|
||||||
# Helper to create a membership fee type (shared across all tests)
|
# Helper to create a membership fee type (shared across all tests)
|
||||||
defp create_fee_type(attrs) do
|
defp create_fee_type(attrs, actor) do
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||||
amount: Decimal.new("50.00"),
|
amount: Decimal.new("50.00"),
|
||||||
|
|
@ -18,18 +18,18 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
MembershipFeeType
|
MembershipFeeType
|
||||||
|> Ash.Changeset.for_create(:create, attrs)
|
|> Ash.Changeset.for_create(:create, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a cycle (shared across all tests)
|
# Helper to create a cycle (shared across all tests)
|
||||||
defp create_cycle(member, fee_type, attrs) do
|
defp create_cycle(member, fee_type, attrs, actor) do
|
||||||
# Delete any auto-generated cycles first to avoid conflicts
|
# Delete any auto-generated cycles first to avoid conflicts
|
||||||
existing_cycles =
|
existing_cycles =
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member.id)
|
|> Ash.Query.filter(member_id == ^member.id)
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle) end)
|
Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle, actor: actor) end)
|
||||||
|
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
cycle_start: ~D[2023-01-01],
|
cycle_start: ~D[2023-01-01],
|
||||||
|
|
@ -43,7 +43,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|> Ash.Changeset.for_create(:create, attrs)
|
|> Ash.Changeset.for_create(:create, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows translated title in German", %{conn: conn} do
|
test "shows translated title in German", %{conn: conn} do
|
||||||
|
|
@ -266,13 +266,18 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can delete a member without error", %{conn: conn} do
|
test "can delete a member without error", %{conn: conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Create a test member first
|
# Create a test member first
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Test",
|
%{
|
||||||
last_name: "User",
|
first_name: "Test",
|
||||||
email: "test@example.com"
|
last_name: "User",
|
||||||
})
|
email: "test@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, index_view, _html} = live(conn, "/members")
|
{:ok, index_view, _html} = live(conn, "/members")
|
||||||
|
|
@ -294,27 +299,38 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
describe "copy_emails feature" do
|
describe "copy_emails feature" do
|
||||||
setup do
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Create test members
|
# Create test members
|
||||||
{:ok, member1} =
|
{:ok, member1} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Max",
|
%{
|
||||||
last_name: "Mustermann",
|
first_name: "Max",
|
||||||
email: "max@example.com"
|
last_name: "Mustermann",
|
||||||
})
|
email: "max@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, member2} =
|
{:ok, member2} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Erika",
|
%{
|
||||||
last_name: "Musterfrau",
|
first_name: "Erika",
|
||||||
email: "erika@example.com"
|
last_name: "Musterfrau",
|
||||||
})
|
email: "erika@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, member3} =
|
{:ok, member3} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Hans",
|
%{
|
||||||
last_name: "Müller-Lüdenscheidt",
|
first_name: "Hans",
|
||||||
email: "hans@example.com"
|
last_name: "Müller-Lüdenscheidt",
|
||||||
})
|
email: "hans@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
%{member1: member1, member2: member2, member3: member3}
|
%{member1: member1, member2: member2, member3: member3}
|
||||||
end
|
end
|
||||||
|
|
@ -394,7 +410,8 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
render_click(view, "select_member", %{"id" => member1.id})
|
render_click(view, "select_member", %{"id" => member1.id})
|
||||||
|
|
||||||
# Delete the member from the database
|
# 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
|
# Trigger copy_emails event directly - selection still contains the deleted ID
|
||||||
# but the member is no longer in @members list after reload
|
# but the member is no longer in @members list after reload
|
||||||
|
|
@ -434,12 +451,17 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
|
|
||||||
# Create a member with known data
|
# Create a member with known data
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
{:ok, test_member} =
|
{:ok, test_member} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Test",
|
%{
|
||||||
last_name: "Format",
|
first_name: "Test",
|
||||||
email: "test.format@example.com"
|
last_name: "Format",
|
||||||
})
|
email: "test.format@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
|
|
@ -500,8 +522,8 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "cycle status filter" do
|
describe "cycle status filter" do
|
||||||
# Helper to create a member
|
# Helper to create a member (only used in this describe block)
|
||||||
defp create_member(attrs) do
|
defp create_member(attrs, actor) do
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
first_name: "Test",
|
first_name: "Test",
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
|
|
@ -512,32 +534,49 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "filter shows only members with paid status in last cycle", %{conn: conn} do
|
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)
|
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()
|
today = Date.utc_today()
|
||||||
last_year_start = Date.new!(today.year - 1, 1, 1)
|
last_year_start = Date.new!(today.year - 1, 1, 1)
|
||||||
|
|
||||||
# Member with paid last cycle
|
# Member with paid last cycle
|
||||||
paid_member =
|
paid_member =
|
||||||
create_member(%{
|
create_member(
|
||||||
first_name: "PaidLast",
|
%{
|
||||||
membership_fee_type_id: fee_type.id
|
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
|
# Member with unpaid last cycle
|
||||||
unpaid_member =
|
unpaid_member =
|
||||||
create_member(%{
|
create_member(
|
||||||
first_name: "UnpaidLast",
|
%{
|
||||||
membership_fee_type_id: fee_type.id
|
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")
|
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid")
|
||||||
|
|
||||||
|
|
@ -546,28 +585,45 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "filter shows only members with unpaid status in last cycle", %{conn: conn} do
|
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)
|
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()
|
today = Date.utc_today()
|
||||||
last_year_start = Date.new!(today.year - 1, 1, 1)
|
last_year_start = Date.new!(today.year - 1, 1, 1)
|
||||||
|
|
||||||
# Member with paid last cycle
|
# Member with paid last cycle
|
||||||
paid_member =
|
paid_member =
|
||||||
create_member(%{
|
create_member(
|
||||||
first_name: "PaidLast",
|
%{
|
||||||
membership_fee_type_id: fee_type.id
|
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
|
# Member with unpaid last cycle
|
||||||
unpaid_member =
|
unpaid_member =
|
||||||
create_member(%{
|
create_member(
|
||||||
first_name: "UnpaidLast",
|
%{
|
||||||
membership_fee_type_id: fee_type.id
|
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")
|
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=unpaid")
|
||||||
|
|
||||||
|
|
@ -576,28 +632,45 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "filter shows only members with paid status in current cycle", %{conn: conn} do
|
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)
|
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()
|
today = Date.utc_today()
|
||||||
current_year_start = Date.new!(today.year, 1, 1)
|
current_year_start = Date.new!(today.year, 1, 1)
|
||||||
|
|
||||||
# Member with paid current cycle
|
# Member with paid current cycle
|
||||||
paid_member =
|
paid_member =
|
||||||
create_member(%{
|
create_member(
|
||||||
first_name: "PaidCurrent",
|
%{
|
||||||
membership_fee_type_id: fee_type.id
|
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
|
# Member with unpaid current cycle
|
||||||
unpaid_member =
|
unpaid_member =
|
||||||
create_member(%{
|
create_member(
|
||||||
first_name: "UnpaidCurrent",
|
%{
|
||||||
membership_fee_type_id: fee_type.id
|
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")
|
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid&show_current_cycle=true")
|
||||||
|
|
||||||
|
|
@ -606,28 +679,45 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "filter shows only members with unpaid status in current cycle", %{conn: conn} do
|
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)
|
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()
|
today = Date.utc_today()
|
||||||
current_year_start = Date.new!(today.year, 1, 1)
|
current_year_start = Date.new!(today.year, 1, 1)
|
||||||
|
|
||||||
# Member with paid current cycle
|
# Member with paid current cycle
|
||||||
paid_member =
|
paid_member =
|
||||||
create_member(%{
|
create_member(
|
||||||
first_name: "PaidCurrent",
|
%{
|
||||||
membership_fee_type_id: fee_type.id
|
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
|
# Member with unpaid current cycle
|
||||||
unpaid_member =
|
unpaid_member =
|
||||||
create_member(%{
|
create_member(
|
||||||
first_name: "UnpaidCurrent",
|
%{
|
||||||
membership_fee_type_id: fee_type.id
|
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} =
|
{:ok, _view, html} =
|
||||||
live(conn, "/members?cycle_status_filter=unpaid&show_current_cycle=true")
|
live(conn, "/members?cycle_status_filter=unpaid&show_current_cycle=true")
|
||||||
|
|
@ -1031,7 +1121,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a member with a boolean custom field value
|
# Helper to create a member with a boolean custom field value
|
||||||
defp create_member_with_boolean_value(member_attrs, custom_field, value) do
|
defp create_member_with_boolean_value(member_attrs, custom_field, value, actor) do
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Ash.Changeset.for_create(
|
|> Ash.Changeset.for_create(
|
||||||
|
|
@ -1043,7 +1133,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
}
|
}
|
||||||
|> Map.merge(member_attrs)
|
|> Map.merge(member_attrs)
|
||||||
)
|
)
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
{:ok, _cfv} =
|
{:ok, _cfv} =
|
||||||
Mv.Membership.CustomFieldValue
|
Mv.Membership.CustomFieldValue
|
||||||
|
|
@ -1052,17 +1142,18 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
custom_field_id: custom_field.id,
|
custom_field_id: custom_field.id,
|
||||||
value: %{"_union_type" => "boolean", "_union_value" => value}
|
value: %{"_union_type" => "boolean", "_union_value" => value}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# Reload member with custom field values
|
# Reload member with custom field values
|
||||||
member
|
member
|
||||||
|> Ash.load!(:custom_field_values)
|
|> Ash.load!(:custom_field_values, actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Tests for get_boolean_custom_field_value/2
|
# Tests for get_boolean_custom_field_value/2
|
||||||
test "get_boolean_custom_field_value extracts true from Ash.Union format", %{conn: _conn} do
|
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()
|
boolean_field = create_boolean_custom_field()
|
||||||
member = create_member_with_boolean_value(%{}, boolean_field, true)
|
member = create_member_with_boolean_value(%{}, boolean_field, true, system_actor)
|
||||||
|
|
||||||
# Test the function (will fail until implemented)
|
# Test the function (will fail until implemented)
|
||||||
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
|
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
|
||||||
|
|
@ -1071,8 +1162,9 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "get_boolean_custom_field_value extracts false from Ash.Union format", %{conn: _conn} do
|
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()
|
boolean_field = create_boolean_custom_field()
|
||||||
member = create_member_with_boolean_value(%{}, boolean_field, false)
|
member = create_member_with_boolean_value(%{}, boolean_field, false, system_actor)
|
||||||
|
|
||||||
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
|
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
|
||||||
|
|
||||||
|
|
@ -1081,6 +1173,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
test "get_boolean_custom_field_value extracts true from map format with _union_type and _union_value keys",
|
test "get_boolean_custom_field_value extracts true from map format with _union_type and _union_value keys",
|
||||||
%{conn: _conn} do
|
%{conn: _conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
|
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
|
|
@ -1090,7 +1183,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Create CustomFieldValue with map format (Ash expects _union_type and _union_value)
|
# Create CustomFieldValue with map format (Ash expects _union_type and _union_value)
|
||||||
{:ok, _cfv} =
|
{:ok, _cfv} =
|
||||||
|
|
@ -1100,10 +1193,10 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
custom_field_id: boolean_field.id,
|
custom_field_id: boolean_field.id,
|
||||||
value: %{"_union_type" => "boolean", "_union_value" => true}
|
value: %{"_union_type" => "boolean", "_union_value" => true}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Reload member with custom field values
|
# Reload member with custom field values
|
||||||
member = member |> Ash.load!(:custom_field_values)
|
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
|
||||||
|
|
||||||
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
|
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
|
||||||
|
|
||||||
|
|
@ -1113,6 +1206,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
test "get_boolean_custom_field_value returns nil when no CustomFieldValue exists", %{
|
test "get_boolean_custom_field_value returns nil when no CustomFieldValue exists", %{
|
||||||
conn: _conn
|
conn: _conn
|
||||||
} do
|
} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
|
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
|
|
@ -1122,10 +1216,10 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Member has no custom field value for this field
|
# Member has no custom field value for this field
|
||||||
member = member |> Ash.load!(:custom_field_values)
|
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
|
||||||
|
|
||||||
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
|
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
|
||||||
|
|
||||||
|
|
@ -1135,6 +1229,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
test "get_boolean_custom_field_value returns nil when CustomFieldValue has nil value", %{
|
test "get_boolean_custom_field_value returns nil when CustomFieldValue has nil value", %{
|
||||||
conn: _conn
|
conn: _conn
|
||||||
} do
|
} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
|
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
|
|
@ -1144,7 +1239,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Create CustomFieldValue with nil value (edge case)
|
# Create CustomFieldValue with nil value (edge case)
|
||||||
{:ok, _cfv} =
|
{:ok, _cfv} =
|
||||||
|
|
@ -1154,9 +1249,9 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
custom_field_id: boolean_field.id,
|
custom_field_id: boolean_field.id,
|
||||||
value: nil
|
value: nil
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
member = member |> Ash.load!(:custom_field_values)
|
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
|
||||||
|
|
||||||
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
|
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
|
||||||
|
|
||||||
|
|
@ -1166,6 +1261,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
test "get_boolean_custom_field_value returns nil for non-boolean CustomFieldValue", %{
|
test "get_boolean_custom_field_value returns nil for non-boolean CustomFieldValue", %{
|
||||||
conn: _conn
|
conn: _conn
|
||||||
} do
|
} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
string_field = create_string_custom_field()
|
string_field = create_string_custom_field()
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
|
|
||||||
|
|
@ -1176,7 +1272,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Create string custom field value (not boolean)
|
# Create string custom field value (not boolean)
|
||||||
{:ok, _cfv} =
|
{:ok, _cfv} =
|
||||||
|
|
@ -1186,9 +1282,9 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
custom_field_id: string_field.id,
|
custom_field_id: string_field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "test"}
|
value: %{"_union_type" => "string", "_union_value" => "test"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
member = member |> Ash.load!(:custom_field_values)
|
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
|
||||||
|
|
||||||
# Try to get boolean value from string field - should return nil
|
# Try to get boolean value from string field - should return nil
|
||||||
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
|
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
|
||||||
|
|
@ -1199,13 +1295,24 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
# Tests for apply_boolean_custom_field_filters/2
|
# Tests for apply_boolean_custom_field_filters/2
|
||||||
test "apply_boolean_custom_field_filters filters members with true value and excludes false/without values",
|
test "apply_boolean_custom_field_filters filters members with true value and excludes false/without values",
|
||||||
%{conn: _conn} do
|
%{conn: _conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
|
|
||||||
member_with_true =
|
member_with_true =
|
||||||
create_member_with_boolean_value(%{first_name: "TrueMember"}, boolean_field, true)
|
create_member_with_boolean_value(
|
||||||
|
%{first_name: "TrueMember"},
|
||||||
|
boolean_field,
|
||||||
|
true,
|
||||||
|
system_actor
|
||||||
|
)
|
||||||
|
|
||||||
member_with_false =
|
member_with_false =
|
||||||
create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false)
|
create_member_with_boolean_value(
|
||||||
|
%{first_name: "FalseMember"},
|
||||||
|
boolean_field,
|
||||||
|
false,
|
||||||
|
system_actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, member_without_value} =
|
{:ok, member_without_value} =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|
|
@ -1214,9 +1321,10 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
|
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
member_without_value = member_without_value |> Ash.load!(:custom_field_values)
|
member_without_value =
|
||||||
|
member_without_value |> Ash.load!(:custom_field_values, actor: system_actor)
|
||||||
|
|
||||||
members = [member_with_true, member_with_false, member_without_value]
|
members = [member_with_true, member_with_false, member_without_value]
|
||||||
filters = %{to_string(boolean_field.id) => true}
|
filters = %{to_string(boolean_field.id) => true}
|
||||||
|
|
@ -1237,13 +1345,24 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
test "apply_boolean_custom_field_filters filters members with false value and excludes true/without values",
|
test "apply_boolean_custom_field_filters filters members with false value and excludes true/without values",
|
||||||
%{conn: _conn} do
|
%{conn: _conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
|
|
||||||
member_with_true =
|
member_with_true =
|
||||||
create_member_with_boolean_value(%{first_name: "TrueMember"}, boolean_field, true)
|
create_member_with_boolean_value(
|
||||||
|
%{first_name: "TrueMember"},
|
||||||
|
boolean_field,
|
||||||
|
true,
|
||||||
|
system_actor
|
||||||
|
)
|
||||||
|
|
||||||
member_with_false =
|
member_with_false =
|
||||||
create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false)
|
create_member_with_boolean_value(
|
||||||
|
%{first_name: "FalseMember"},
|
||||||
|
boolean_field,
|
||||||
|
false,
|
||||||
|
system_actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, member_without_value} =
|
{:ok, member_without_value} =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|
|
@ -1252,9 +1371,10 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
|
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
member_without_value = member_without_value |> Ash.load!(:custom_field_values)
|
member_without_value =
|
||||||
|
member_without_value |> Ash.load!(:custom_field_values, actor: system_actor)
|
||||||
|
|
||||||
members = [member_with_true, member_with_false, member_without_value]
|
members = [member_with_true, member_with_false, member_without_value]
|
||||||
filters = %{to_string(boolean_field.id) => false}
|
filters = %{to_string(boolean_field.id) => false}
|
||||||
|
|
@ -1276,10 +1396,24 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
test "apply_boolean_custom_field_filters returns all members when filter map is empty", %{
|
test "apply_boolean_custom_field_filters returns all members when filter map is empty", %{
|
||||||
conn: _conn
|
conn: _conn
|
||||||
} do
|
} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
|
|
||||||
member1 = create_member_with_boolean_value(%{first_name: "Member1"}, boolean_field, true)
|
member1 =
|
||||||
member2 = create_member_with_boolean_value(%{first_name: "Member2"}, boolean_field, false)
|
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]
|
members = [member1, member2]
|
||||||
filters = %{}
|
filters = %{}
|
||||||
|
|
@ -1302,6 +1436,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
test "apply_boolean_custom_field_filters applies multiple filters with AND logic", %{
|
test "apply_boolean_custom_field_filters applies multiple filters with AND logic", %{
|
||||||
conn: _conn
|
conn: _conn
|
||||||
} do
|
} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
boolean_field1 = create_boolean_custom_field(%{name: "Field1"})
|
boolean_field1 = create_boolean_custom_field(%{name: "Field1"})
|
||||||
boolean_field2 = create_boolean_custom_field(%{name: "Field2"})
|
boolean_field2 = create_boolean_custom_field(%{name: "Field2"})
|
||||||
|
|
||||||
|
|
@ -1313,7 +1448,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
email: "bothtrue.member.#{System.unique_integer([:positive])}@example.com"
|
email: "bothtrue.member.#{System.unique_integer([:positive])}@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, _cfv1} =
|
{:ok, _cfv1} =
|
||||||
Mv.Membership.CustomFieldValue
|
Mv.Membership.CustomFieldValue
|
||||||
|
|
@ -1322,7 +1457,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
custom_field_id: boolean_field1.id,
|
custom_field_id: boolean_field1.id,
|
||||||
value: %{"_union_type" => "boolean", "_union_value" => true}
|
value: %{"_union_type" => "boolean", "_union_value" => true}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, _cfv2} =
|
{:ok, _cfv2} =
|
||||||
Mv.Membership.CustomFieldValue
|
Mv.Membership.CustomFieldValue
|
||||||
|
|
@ -1331,9 +1466,9 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
custom_field_id: boolean_field2.id,
|
custom_field_id: boolean_field2.id,
|
||||||
value: %{"_union_type" => "boolean", "_union_value" => true}
|
value: %{"_union_type" => "boolean", "_union_value" => true}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
member_both_true = member_both_true |> Ash.load!(:custom_field_values)
|
member_both_true = member_both_true |> Ash.load!(:custom_field_values, actor: system_actor)
|
||||||
|
|
||||||
# Member with field1 = true, field2 = false
|
# Member with field1 = true, field2 = false
|
||||||
{:ok, member_mixed} =
|
{:ok, member_mixed} =
|
||||||
|
|
@ -1343,7 +1478,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
email: "mixed.member.#{System.unique_integer([:positive])}@example.com"
|
email: "mixed.member.#{System.unique_integer([:positive])}@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, _cfv3} =
|
{:ok, _cfv3} =
|
||||||
Mv.Membership.CustomFieldValue
|
Mv.Membership.CustomFieldValue
|
||||||
|
|
@ -1352,7 +1487,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
custom_field_id: boolean_field1.id,
|
custom_field_id: boolean_field1.id,
|
||||||
value: %{"_union_type" => "boolean", "_union_value" => true}
|
value: %{"_union_type" => "boolean", "_union_value" => true}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, _cfv4} =
|
{:ok, _cfv4} =
|
||||||
Mv.Membership.CustomFieldValue
|
Mv.Membership.CustomFieldValue
|
||||||
|
|
@ -1361,9 +1496,9 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
custom_field_id: boolean_field2.id,
|
custom_field_id: boolean_field2.id,
|
||||||
value: %{"_union_type" => "boolean", "_union_value" => false}
|
value: %{"_union_type" => "boolean", "_union_value" => false}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
member_mixed = member_mixed |> Ash.load!(:custom_field_values)
|
member_mixed = member_mixed |> Ash.load!(:custom_field_values, actor: system_actor)
|
||||||
|
|
||||||
members = [member_both_true, member_mixed]
|
members = [member_both_true, member_mixed]
|
||||||
|
|
||||||
|
|
@ -1389,10 +1524,17 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
test "apply_boolean_custom_field_filters ignores filter with non-existent custom field ID", %{
|
test "apply_boolean_custom_field_filters ignores filter with non-existent custom field ID", %{
|
||||||
conn: _conn
|
conn: _conn
|
||||||
} do
|
} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
fake_id = Ecto.UUID.generate()
|
fake_id = Ecto.UUID.generate()
|
||||||
|
|
||||||
member = create_member_with_boolean_value(%{first_name: "Member"}, boolean_field, true)
|
member =
|
||||||
|
create_member_with_boolean_value(
|
||||||
|
%{first_name: "Member"},
|
||||||
|
boolean_field,
|
||||||
|
true,
|
||||||
|
system_actor
|
||||||
|
)
|
||||||
|
|
||||||
members = [member]
|
members = [member]
|
||||||
filters = %{fake_id => true}
|
filters = %{fake_id => true}
|
||||||
|
|
@ -1412,14 +1554,25 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
# Integration tests for boolean custom field filters in load_members
|
# Integration tests for boolean custom field filters in load_members
|
||||||
test "boolean filter integration filters members by boolean custom field value via URL parameter",
|
test "boolean filter integration filters members by boolean custom field value via URL parameter",
|
||||||
%{conn: conn} do
|
%{conn: conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
|
|
||||||
_member_with_true =
|
_member_with_true =
|
||||||
create_member_with_boolean_value(%{first_name: "TrueMember"}, boolean_field, true)
|
create_member_with_boolean_value(
|
||||||
|
%{first_name: "TrueMember"},
|
||||||
|
boolean_field,
|
||||||
|
true,
|
||||||
|
system_actor
|
||||||
|
)
|
||||||
|
|
||||||
_member_with_false =
|
_member_with_false =
|
||||||
create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false)
|
create_member_with_boolean_value(
|
||||||
|
%{first_name: "FalseMember"},
|
||||||
|
boolean_field,
|
||||||
|
false,
|
||||||
|
system_actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, _member_without_value} =
|
{:ok, _member_without_value} =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|
|
@ -1428,7 +1581,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
|
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Test true filter
|
# Test true filter
|
||||||
{:ok, _view, html_true} =
|
{:ok, _view, html_true} =
|
||||||
|
|
@ -1448,9 +1601,10 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "boolean filter integration works together with cycle_status_filter", %{conn: conn} do
|
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)
|
conn = conn_with_oidc_user(conn)
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
|
||||||
today = Date.utc_today()
|
today = Date.utc_today()
|
||||||
last_year_start = Date.new!(today.year - 1, 1, 1)
|
last_year_start = Date.new!(today.year - 1, 1, 1)
|
||||||
|
|
||||||
|
|
@ -1463,7 +1617,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
email: "paidtrue.member.#{System.unique_integer([:positive])}@example.com",
|
email: "paidtrue.member.#{System.unique_integer([:positive])}@example.com",
|
||||||
membership_fee_type_id: fee_type.id
|
membership_fee_type_id: fee_type.id
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, _cfv} =
|
{:ok, _cfv} =
|
||||||
Mv.Membership.CustomFieldValue
|
Mv.Membership.CustomFieldValue
|
||||||
|
|
@ -1472,9 +1626,14 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
custom_field_id: boolean_field.id,
|
custom_field_id: boolean_field.id,
|
||||||
value: %{"_union_type" => "boolean", "_union_value" => true}
|
value: %{"_union_type" => "boolean", "_union_value" => true}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
create_cycle(member_paid_true, fee_type, %{cycle_start: last_year_start, status: :paid})
|
create_cycle(
|
||||||
|
member_paid_true,
|
||||||
|
fee_type,
|
||||||
|
%{cycle_start: last_year_start, status: :paid},
|
||||||
|
system_actor
|
||||||
|
)
|
||||||
|
|
||||||
# Member with true boolean value but unpaid status
|
# Member with true boolean value but unpaid status
|
||||||
{:ok, member_unpaid_true} =
|
{:ok, member_unpaid_true} =
|
||||||
|
|
@ -1485,7 +1644,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
email: "unpaidtrue.member.#{System.unique_integer([:positive])}@example.com",
|
email: "unpaidtrue.member.#{System.unique_integer([:positive])}@example.com",
|
||||||
membership_fee_type_id: fee_type.id
|
membership_fee_type_id: fee_type.id
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, _cfv2} =
|
{:ok, _cfv2} =
|
||||||
Mv.Membership.CustomFieldValue
|
Mv.Membership.CustomFieldValue
|
||||||
|
|
@ -1494,9 +1653,14 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
custom_field_id: boolean_field.id,
|
custom_field_id: boolean_field.id,
|
||||||
value: %{"_union_type" => "boolean", "_union_value" => true}
|
value: %{"_union_type" => "boolean", "_union_value" => true}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
create_cycle(member_unpaid_true, fee_type, %{cycle_start: last_year_start, status: :unpaid})
|
create_cycle(
|
||||||
|
member_unpaid_true,
|
||||||
|
fee_type,
|
||||||
|
%{cycle_start: last_year_start, status: :unpaid},
|
||||||
|
system_actor
|
||||||
|
)
|
||||||
|
|
||||||
# Test both filters together
|
# Test both filters together
|
||||||
{:ok, _view, html} =
|
{:ok, _view, html} =
|
||||||
|
|
@ -1508,14 +1672,25 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "boolean filter integration works together with search query", %{conn: conn} do
|
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)
|
conn = conn_with_oidc_user(conn)
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
|
|
||||||
_member_with_true =
|
_member_with_true =
|
||||||
create_member_with_boolean_value(%{first_name: "TrueMember"}, boolean_field, true)
|
create_member_with_boolean_value(
|
||||||
|
%{first_name: "TrueMember"},
|
||||||
|
boolean_field,
|
||||||
|
true,
|
||||||
|
system_actor
|
||||||
|
)
|
||||||
|
|
||||||
_member_with_false =
|
_member_with_false =
|
||||||
create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false)
|
create_member_with_boolean_value(
|
||||||
|
%{first_name: "FalseMember"},
|
||||||
|
boolean_field,
|
||||||
|
false,
|
||||||
|
system_actor
|
||||||
|
)
|
||||||
|
|
||||||
# Test search + boolean filter
|
# Test search + boolean filter
|
||||||
{:ok, _view, html} =
|
{:ok, _view, html} =
|
||||||
|
|
@ -1527,16 +1702,27 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "boolean filter works even when custom field is not visible in overview", %{conn: conn} do
|
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)
|
conn = conn_with_oidc_user(conn)
|
||||||
|
|
||||||
# Create boolean field with show_in_overview: false
|
# Create boolean field with show_in_overview: false
|
||||||
boolean_field = create_boolean_custom_field(%{show_in_overview: false})
|
boolean_field = create_boolean_custom_field(%{show_in_overview: false})
|
||||||
|
|
||||||
_member_with_true =
|
_member_with_true =
|
||||||
create_member_with_boolean_value(%{first_name: "TrueMember"}, boolean_field, true)
|
create_member_with_boolean_value(
|
||||||
|
%{first_name: "TrueMember"},
|
||||||
|
boolean_field,
|
||||||
|
true,
|
||||||
|
system_actor
|
||||||
|
)
|
||||||
|
|
||||||
_member_with_false =
|
_member_with_false =
|
||||||
create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false)
|
create_member_with_boolean_value(
|
||||||
|
%{first_name: "FalseMember"},
|
||||||
|
boolean_field,
|
||||||
|
false,
|
||||||
|
system_actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, _member_without_value} =
|
{:ok, _member_without_value} =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|
|
@ -1545,7 +1731,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
|
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
# Test that filter works even though field is not visible in overview
|
# Test that filter works even though field is not visible in overview
|
||||||
{:ok, _view, html_true} =
|
{:ok, _view, html_true} =
|
||||||
|
|
@ -1590,6 +1776,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "boolean filter performance with 150 members", %{conn: conn} do
|
test "boolean filter performance with 150 members", %{conn: conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
|
|
||||||
|
|
@ -1602,7 +1789,8 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
email: "truemember#{i}@example.com"
|
email: "truemember#{i}@example.com"
|
||||||
},
|
},
|
||||||
boolean_field,
|
boolean_field,
|
||||||
true
|
true,
|
||||||
|
system_actor
|
||||||
)
|
)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -1614,7 +1802,8 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
email: "falsemember#{i}@example.com"
|
email: "falsemember#{i}@example.com"
|
||||||
},
|
},
|
||||||
boolean_field,
|
boolean_field,
|
||||||
false
|
false,
|
||||||
|
system_actor
|
||||||
)
|
)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
|
||||||
|
|
||||||
# Helper to create a membership fee type
|
# Helper to create a membership fee type
|
||||||
defp create_fee_type(attrs) do
|
defp create_fee_type(attrs) do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||||
amount: Decimal.new("50.00"),
|
amount: Decimal.new("50.00"),
|
||||||
|
|
@ -24,11 +26,13 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
|
||||||
|
|
||||||
MembershipFeeType
|
MembershipFeeType
|
||||||
|> Ash.Changeset.for_create(:create, attrs)
|
|> Ash.Changeset.for_create(:create, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: system_actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a member
|
# Helper to create a member
|
||||||
defp create_member(attrs) do
|
defp create_member(attrs) do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
first_name: "Test",
|
first_name: "Test",
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
|
|
@ -39,7 +43,7 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
|
||||||
|
|
||||||
Member
|
Member
|
||||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: system_actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "end-to-end workflows" do
|
describe "end-to-end workflows" do
|
||||||
|
|
@ -75,7 +79,13 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Verify status changed
|
# 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
|
assert updated_cycle.status == :paid
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -115,13 +125,14 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly})
|
||||||
|
|
||||||
# Update settings
|
# Update settings
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
{:ok, settings} = Mv.Membership.get_settings()
|
{:ok, settings} = Mv.Membership.get_settings()
|
||||||
|
|
||||||
settings
|
settings
|
||||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
||||||
default_membership_fee_type_id: fee_type.id
|
default_membership_fee_type_id: fee_type.id
|
||||||
})
|
})
|
||||||
|> Ash.update!()
|
|> Ash.update!(actor: system_actor)
|
||||||
|
|
||||||
# Create new member
|
# Create new member
|
||||||
{:ok, view, _html} = live(conn, "/members/new")
|
{:ok, view, _html} = live(conn, "/members/new")
|
||||||
|
|
@ -138,10 +149,12 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
|
||||||
|> render_submit()
|
|> render_submit()
|
||||||
|
|
||||||
# Verify member got default type
|
# Verify member got default type
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
member =
|
member =
|
||||||
Member
|
Member
|
||||||
|> Ash.Query.filter(email == ^form_data["member[email]"])
|
|> 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
|
assert member.membership_fee_type_id == fee_type.id
|
||||||
end
|
end
|
||||||
|
|
@ -150,6 +163,8 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly})
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||||
|
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
cycle =
|
cycle =
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
|
@ -159,7 +174,7 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
|
||||||
membership_fee_type_id: fee_type.id,
|
membership_fee_type_id: fee_type.id,
|
||||||
status: :unpaid
|
status: :unpaid
|
||||||
})
|
})
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: system_actor)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||||
|
|
||||||
|
|
@ -187,6 +202,8 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly})
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||||
|
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
cycle =
|
cycle =
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
|
@ -196,7 +213,7 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
|
||||||
membership_fee_type_id: fee_type.id,
|
membership_fee_type_id: fee_type.id,
|
||||||
status: :unpaid
|
status: :unpaid
|
||||||
})
|
})
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: system_actor)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||||
|
|
||||||
|
|
@ -216,7 +233,13 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
|
||||||
|> render_submit()
|
|> render_submit()
|
||||||
|
|
||||||
# Verify amount updated
|
# 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")
|
assert updated_cycle.amount == Decimal.new("75.00")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
||||||
|
|
||||||
# Helper to create a membership fee type
|
# Helper to create a membership fee type
|
||||||
defp create_fee_type(attrs) do
|
defp create_fee_type(attrs) do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||||
amount: Decimal.new("50.00"),
|
amount: Decimal.new("50.00"),
|
||||||
|
|
@ -24,11 +26,13 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
||||||
|
|
||||||
MembershipFeeType
|
MembershipFeeType
|
||||||
|> Ash.Changeset.for_create(:create, attrs)
|
|> Ash.Changeset.for_create(:create, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: system_actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a member
|
# Helper to create a member
|
||||||
defp create_member(attrs) do
|
defp create_member(attrs) do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
first_name: "Test",
|
first_name: "Test",
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
|
|
@ -39,18 +43,20 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
||||||
|
|
||||||
Member
|
Member
|
||||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: system_actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a cycle
|
# Helper to create a cycle
|
||||||
defp create_cycle(member, fee_type, attrs) do
|
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
|
# Delete any auto-generated cycles first to avoid conflicts
|
||||||
existing_cycles =
|
existing_cycles =
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member.id)
|
|> 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 = %{
|
default_attrs = %{
|
||||||
cycle_start: ~D[2023-01-01],
|
cycle_start: ~D[2023-01-01],
|
||||||
|
|
@ -64,7 +70,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
||||||
|
|
||||||
MembershipFeeCycle
|
MembershipFeeCycle
|
||||||
|> Ash.Changeset.for_create(:create, attrs)
|
|> Ash.Changeset.for_create(:create, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(actor: system_actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "cycles table display" do
|
describe "cycles table display" do
|
||||||
|
|
@ -161,7 +167,13 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Verify cycle is now paid
|
# 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
|
assert updated_cycle.status == :paid
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -186,7 +198,13 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Verify cycle is now suspended
|
# 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
|
assert updated_cycle.status == :suspended
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -211,7 +229,13 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Verify cycle is now unpaid
|
# 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
|
assert updated_cycle.status == :unpaid
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ defmodule MvWeb.MemberLive.ShowTest do
|
||||||
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Create test member
|
# Create test member
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Member
|
Member
|
||||||
|
|
@ -29,15 +31,16 @@ defmodule MvWeb.MemberLive.ShowTest do
|
||||||
last_name: "Anderson",
|
last_name: "Anderson",
|
||||||
email: "alice@example.com"
|
email: "alice@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
%{member: member}
|
%{member: member, actor: system_actor}
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "custom fields section visibility (Issue #282)" do
|
describe "custom fields section visibility (Issue #282)" do
|
||||||
test "displays Custom Fields section even when member has no custom field values", %{
|
test "displays Custom Fields section even when member has no custom field values", %{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
member: member
|
member: member,
|
||||||
|
actor: actor
|
||||||
} do
|
} do
|
||||||
# Create a custom field but no value for the member
|
# Create a custom field but no value for the member
|
||||||
{:ok, custom_field} =
|
{:ok, custom_field} =
|
||||||
|
|
@ -46,7 +49,7 @@ defmodule MvWeb.MemberLive.ShowTest do
|
||||||
name: "phone_mobile",
|
name: "phone_mobile",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, _view, html} = live(conn, ~p"/members/#{member}")
|
{: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", %{
|
test "displays Custom Fields section with multiple custom fields, some without values", %{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
member: member
|
member: member,
|
||||||
|
actor: actor
|
||||||
} do
|
} do
|
||||||
# Create multiple custom fields
|
# Create multiple custom fields
|
||||||
{:ok, field1} =
|
{:ok, field1} =
|
||||||
|
|
@ -72,7 +76,7 @@ defmodule MvWeb.MemberLive.ShowTest do
|
||||||
name: "phone_mobile",
|
name: "phone_mobile",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
{:ok, field2} =
|
{:ok, field2} =
|
||||||
CustomField
|
CustomField
|
||||||
|
|
@ -80,7 +84,7 @@ defmodule MvWeb.MemberLive.ShowTest do
|
||||||
name: "membership_number",
|
name: "membership_number",
|
||||||
value_type: :integer
|
value_type: :integer
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
# Create value only for first field
|
# Create value only for first field
|
||||||
{:ok, _cfv} =
|
{:ok, _cfv} =
|
||||||
|
|
@ -90,7 +94,7 @@ defmodule MvWeb.MemberLive.ShowTest do
|
||||||
custom_field_id: field1.id,
|
custom_field_id: field1.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "+49123456789"}
|
value: %{"_union_type" => "string", "_union_value" => "+49123456789"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, _view, html} = live(conn, ~p"/members/#{member}")
|
{: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", %{
|
test "does not display Custom Fields section when no custom fields exist", %{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
member: member
|
member: member,
|
||||||
|
actor: actor
|
||||||
} do
|
} do
|
||||||
# Ensure no custom fields exist for this test
|
# Ensure no custom fields exist for this test
|
||||||
# This ensures test isolation even if previous tests created custom fields
|
# 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
|
for cf <- existing_custom_fields do
|
||||||
Ash.destroy!(cf)
|
Ash.destroy!(cf, actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Verify no custom fields exist
|
# Verify no custom fields exist
|
||||||
assert Ash.read!(CustomField) == []
|
assert Ash.read!(CustomField, actor: actor) == []
|
||||||
|
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, _view, html} = live(conn, ~p"/members/#{member}")
|
{:ok, _view, html} = live(conn, ~p"/members/#{member}")
|
||||||
|
|
@ -133,14 +138,14 @@ defmodule MvWeb.MemberLive.ShowTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "custom field value formatting" do
|
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} =
|
{:ok, custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "phone_mobile",
|
name: "phone_mobile",
|
||||||
value_type: :string
|
value_type: :string
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
{:ok, _cfv} =
|
{:ok, _cfv} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|
|
@ -149,7 +154,7 @@ defmodule MvWeb.MemberLive.ShowTest do
|
||||||
custom_field_id: custom_field.id,
|
custom_field_id: custom_field.id,
|
||||||
value: %{"_union_type" => "string", "_union_value" => "+49123456789"}
|
value: %{"_union_type" => "string", "_union_value" => "+49123456789"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, _view, html} = live(conn, ~p"/members/#{member}")
|
{:ok, _view, html} = live(conn, ~p"/members/#{member}")
|
||||||
|
|
@ -157,14 +162,18 @@ defmodule MvWeb.MemberLive.ShowTest do
|
||||||
assert html =~ "+49123456789"
|
assert html =~ "+49123456789"
|
||||||
end
|
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} =
|
{:ok, custom_field} =
|
||||||
CustomField
|
CustomField
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
name: "private_email",
|
name: "private_email",
|
||||||
value_type: :email
|
value_type: :email
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
{:ok, _cfv} =
|
{:ok, _cfv} =
|
||||||
CustomFieldValue
|
CustomFieldValue
|
||||||
|
|
@ -173,7 +182,7 @@ defmodule MvWeb.MemberLive.ShowTest do
|
||||||
custom_field_id: custom_field.id,
|
custom_field_id: custom_field.id,
|
||||||
value: %{"_union_type" => "email", "_union_value" => "private@example.com"}
|
value: %{"_union_type" => "email", "_union_value" => "private@example.com"}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, _view, html} = live(conn, ~p"/members/#{member}")
|
{:ok, _view, html} = live(conn, ~p"/members/#{member}")
|
||||||
|
|
|
||||||
|
|
@ -70,12 +70,17 @@ defmodule MvWeb.UserLive.FormMemberDropdownTest do
|
||||||
test "links user and member with identical email successfully", %{conn: conn} do
|
test "links user and member with identical email successfully", %{conn: conn} do
|
||||||
conn = setup_admin_conn(conn)
|
conn = setup_admin_conn(conn)
|
||||||
|
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "David",
|
%{
|
||||||
last_name: "Miller",
|
first_name: "David",
|
||||||
email: "david@example.com"
|
last_name: "Miller",
|
||||||
})
|
email: "david@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/users/new")
|
{: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
|
test "shows member with same email in dropdown", %{conn: conn} do
|
||||||
conn = setup_admin_conn(conn)
|
conn = setup_admin_conn(conn)
|
||||||
|
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
{:ok, _member} =
|
{:ok, _member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Emma",
|
%{
|
||||||
last_name: "Davis",
|
first_name: "Emma",
|
||||||
email: "emma@example.com"
|
last_name: "Davis",
|
||||||
})
|
email: "emma@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/users/new")
|
{:ok, view, _html} = live(conn, ~p"/users/new")
|
||||||
|
|
||||||
|
|
@ -135,13 +145,18 @@ defmodule MvWeb.UserLive.FormMemberDropdownTest do
|
||||||
|
|
||||||
# Helper functions
|
# Helper functions
|
||||||
defp create_unlinked_members(count) do
|
defp create_unlinked_members(count) do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
for i <- 1..count do
|
for i <- 1..count do
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "FirstName#{i}",
|
%{
|
||||||
last_name: "LastName#{i}",
|
first_name: "FirstName#{i}",
|
||||||
email: "member#{i}@example.com"
|
last_name: "LastName#{i}",
|
||||||
})
|
email: "member#{i}@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
member
|
member
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,18 @@ defmodule MvWeb.UserLive.FormMemberSearchTest do
|
||||||
|
|
||||||
describe "fuzzy search" do
|
describe "fuzzy search" do
|
||||||
test "finds member with exact name", %{conn: conn} do
|
test "finds member with exact name", %{conn: conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
conn = setup_admin_conn(conn)
|
conn = setup_admin_conn(conn)
|
||||||
|
|
||||||
{:ok, _member} =
|
{:ok, _member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Jonathan",
|
%{
|
||||||
last_name: "Smith",
|
first_name: "Jonathan",
|
||||||
email: "jonathan.smith@example.com"
|
last_name: "Smith",
|
||||||
})
|
email: "jonathan.smith@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/users/new")
|
{:ok, view, _html} = live(conn, ~p"/users/new")
|
||||||
|
|
||||||
|
|
@ -41,14 +45,18 @@ defmodule MvWeb.UserLive.FormMemberSearchTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "finds member with typo (Jon finds Jonathan)", %{conn: conn} do
|
test "finds member with typo (Jon finds Jonathan)", %{conn: conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
conn = setup_admin_conn(conn)
|
conn = setup_admin_conn(conn)
|
||||||
|
|
||||||
{:ok, _member} =
|
{:ok, _member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Jonathan",
|
%{
|
||||||
last_name: "Smith",
|
first_name: "Jonathan",
|
||||||
email: "jonathan.smith@example.com"
|
last_name: "Smith",
|
||||||
})
|
email: "jonathan.smith@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/users/new")
|
{:ok, view, _html} = live(conn, ~p"/users/new")
|
||||||
|
|
||||||
|
|
@ -65,14 +73,18 @@ defmodule MvWeb.UserLive.FormMemberSearchTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "finds member with partial substring", %{conn: conn} do
|
test "finds member with partial substring", %{conn: conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
conn = setup_admin_conn(conn)
|
conn = setup_admin_conn(conn)
|
||||||
|
|
||||||
{:ok, _member} =
|
{:ok, _member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Alexander",
|
%{
|
||||||
last_name: "Williams",
|
first_name: "Alexander",
|
||||||
email: "alex@example.com"
|
last_name: "Williams",
|
||||||
})
|
email: "alex@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/users/new")
|
{:ok, view, _html} = live(conn, ~p"/users/new")
|
||||||
|
|
||||||
|
|
@ -87,14 +99,18 @@ defmodule MvWeb.UserLive.FormMemberSearchTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows partial match with similar names", %{conn: conn} do
|
test "shows partial match with similar names", %{conn: conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
conn = setup_admin_conn(conn)
|
conn = setup_admin_conn(conn)
|
||||||
|
|
||||||
{:ok, _member} =
|
{:ok, _member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Johnny",
|
%{
|
||||||
last_name: "Doeson",
|
first_name: "Johnny",
|
||||||
email: "johnny@example.com"
|
last_name: "Doeson",
|
||||||
})
|
email: "johnny@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/users/new")
|
{:ok, view, _html} = live(conn, ~p"/users/new")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,18 @@ defmodule MvWeb.UserLive.FormMemberSelectionTest do
|
||||||
|
|
||||||
describe "member selection" do
|
describe "member selection" do
|
||||||
test "input field shows selected member name", %{conn: conn} do
|
test "input field shows selected member name", %{conn: conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
conn = setup_admin_conn(conn)
|
conn = setup_admin_conn(conn)
|
||||||
|
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Alice",
|
%{
|
||||||
last_name: "Johnson",
|
first_name: "Alice",
|
||||||
email: "alice@example.com"
|
last_name: "Johnson",
|
||||||
})
|
email: "alice@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/users/new")
|
{:ok, view, _html} = live(conn, ~p"/users/new")
|
||||||
|
|
||||||
|
|
@ -47,14 +51,18 @@ defmodule MvWeb.UserLive.FormMemberSelectionTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "confirmation box appears", %{conn: conn} do
|
test "confirmation box appears", %{conn: conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
conn = setup_admin_conn(conn)
|
conn = setup_admin_conn(conn)
|
||||||
|
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Bob",
|
%{
|
||||||
last_name: "Williams",
|
first_name: "Bob",
|
||||||
email: "bob@example.com"
|
last_name: "Williams",
|
||||||
})
|
email: "bob@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/users/new")
|
{:ok, view, _html} = live(conn, ~p"/users/new")
|
||||||
|
|
||||||
|
|
@ -77,14 +85,18 @@ defmodule MvWeb.UserLive.FormMemberSelectionTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "hidden input stores member ID", %{conn: conn} do
|
test "hidden input stores member ID", %{conn: conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
conn = setup_admin_conn(conn)
|
conn = setup_admin_conn(conn)
|
||||||
|
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Charlie",
|
%{
|
||||||
last_name: "Brown",
|
first_name: "Charlie",
|
||||||
email: "charlie@example.com"
|
last_name: "Brown",
|
||||||
})
|
email: "charlie@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/users/new")
|
{:ok, view, _html} = live(conn, ~p"/users/new")
|
||||||
|
|
||||||
|
|
@ -105,20 +117,27 @@ defmodule MvWeb.UserLive.FormMemberSelectionTest do
|
||||||
|
|
||||||
describe "unlink workflow" do
|
describe "unlink workflow" do
|
||||||
test "unlink hides dropdown", %{conn: conn} do
|
test "unlink hides dropdown", %{conn: conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
conn = setup_admin_conn(conn)
|
conn = setup_admin_conn(conn)
|
||||||
# Create user with linked member
|
# Create user with linked member
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Frank",
|
%{
|
||||||
last_name: "Wilson",
|
first_name: "Frank",
|
||||||
email: "frank@example.com"
|
last_name: "Wilson",
|
||||||
})
|
email: "frank@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "frank@example.com",
|
%{
|
||||||
member: %{id: member.id}
|
email: "frank@example.com",
|
||||||
})
|
member: %{id: member.id}
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit")
|
{:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit")
|
||||||
|
|
||||||
|
|
@ -134,20 +153,27 @@ defmodule MvWeb.UserLive.FormMemberSelectionTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "unlink shows warning", %{conn: conn} do
|
test "unlink shows warning", %{conn: conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
conn = setup_admin_conn(conn)
|
conn = setup_admin_conn(conn)
|
||||||
# Create user with linked member
|
# Create user with linked member
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Grace",
|
%{
|
||||||
last_name: "Taylor",
|
first_name: "Grace",
|
||||||
email: "grace@example.com"
|
last_name: "Taylor",
|
||||||
})
|
email: "grace@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "grace@example.com",
|
%{
|
||||||
member: %{id: member.id}
|
email: "grace@example.com",
|
||||||
})
|
member: %{id: member.id}
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit")
|
{:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit")
|
||||||
|
|
||||||
|
|
@ -164,20 +190,27 @@ defmodule MvWeb.UserLive.FormMemberSelectionTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "unlink disables input", %{conn: conn} do
|
test "unlink disables input", %{conn: conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
conn = setup_admin_conn(conn)
|
conn = setup_admin_conn(conn)
|
||||||
# Create user with linked member
|
# Create user with linked member
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Henry",
|
%{
|
||||||
last_name: "Anderson",
|
first_name: "Henry",
|
||||||
email: "henry@example.com"
|
last_name: "Anderson",
|
||||||
})
|
email: "henry@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "henry@example.com",
|
%{
|
||||||
member: %{id: member.id}
|
email: "henry@example.com",
|
||||||
})
|
member: %{id: member.id}
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit")
|
{:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit")
|
||||||
|
|
||||||
|
|
@ -193,20 +226,27 @@ defmodule MvWeb.UserLive.FormMemberSelectionTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "save re-enables member selection", %{conn: conn} do
|
test "save re-enables member selection", %{conn: conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
conn = setup_admin_conn(conn)
|
conn = setup_admin_conn(conn)
|
||||||
# Create user with linked member
|
# Create user with linked member
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(%{
|
Membership.create_member(
|
||||||
first_name: "Isabel",
|
%{
|
||||||
last_name: "Martinez",
|
first_name: "Isabel",
|
||||||
email: "isabel@example.com"
|
last_name: "Martinez",
|
||||||
})
|
email: "isabel@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
Accounts.create_user(%{
|
Accounts.create_user(
|
||||||
email: "isabel@example.com",
|
%{
|
||||||
member: %{id: member.id}
|
email: "isabel@example.com",
|
||||||
})
|
member: %{id: member.id}
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit")
|
{:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,11 +75,14 @@ defmodule MvWeb.UserLive.FormTest do
|
||||||
|> form("#user-form", user: %{email: "storetest@example.com"})
|
|> form("#user-form", user: %{email: "storetest@example.com"})
|
||||||
|> render_submit()
|
|> render_submit()
|
||||||
|
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
user =
|
user =
|
||||||
Ash.get!(
|
Ash.get!(
|
||||||
Mv.Accounts.User,
|
Mv.Accounts.User,
|
||||||
[email: Ash.CiString.new("storetest@example.com")],
|
[email: Ash.CiString.new("storetest@example.com")],
|
||||||
domain: Mv.Accounts
|
domain: Mv.Accounts,
|
||||||
|
actor: system_actor
|
||||||
)
|
)
|
||||||
|
|
||||||
assert to_string(user.email) == "storetest@example.com"
|
assert to_string(user.email) == "storetest@example.com"
|
||||||
|
|
@ -101,11 +104,14 @@ defmodule MvWeb.UserLive.FormTest do
|
||||||
)
|
)
|
||||||
|> render_submit()
|
|> render_submit()
|
||||||
|
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
user =
|
user =
|
||||||
Ash.get!(
|
Ash.get!(
|
||||||
Mv.Accounts.User,
|
Mv.Accounts.User,
|
||||||
[email: Ash.CiString.new("passwordstoretest@example.com")],
|
[email: Ash.CiString.new("passwordstoretest@example.com")],
|
||||||
domain: Mv.Accounts
|
domain: Mv.Accounts,
|
||||||
|
actor: system_actor
|
||||||
)
|
)
|
||||||
|
|
||||||
assert user.hashed_password != nil
|
assert user.hashed_password != nil
|
||||||
|
|
@ -181,7 +187,8 @@ defmodule MvWeb.UserLive.FormTest do
|
||||||
|
|
||||||
assert_redirected(view, "/users")
|
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 to_string(updated_user.email) == "new@example.com"
|
||||||
assert updated_user.hashed_password == original_password
|
assert updated_user.hashed_password == original_password
|
||||||
end
|
end
|
||||||
|
|
@ -204,7 +211,8 @@ defmodule MvWeb.UserLive.FormTest do
|
||||||
|
|
||||||
assert_redirected(view, "/users")
|
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 updated_user.hashed_password != original_password
|
||||||
assert String.starts_with?(updated_user.hashed_password, "$2b$")
|
assert String.starts_with?(updated_user.hashed_password, "$2b$")
|
||||||
end
|
end
|
||||||
|
|
@ -285,17 +293,24 @@ defmodule MvWeb.UserLive.FormTest do
|
||||||
|
|
||||||
describe "member linking - display" do
|
describe "member linking - display" do
|
||||||
test "shows linked member with unlink button when user has member", %{conn: conn} 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
|
# Create member
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "John",
|
%{
|
||||||
last_name: "Doe",
|
first_name: "John",
|
||||||
email: "john@example.com"
|
last_name: "Doe",
|
||||||
})
|
email: "john@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
# Create user linked to member
|
# Create user linked to member
|
||||||
user = create_test_user(%{email: "user@example.com"})
|
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
|
# Load form
|
||||||
{:ok, view, html} = setup_live_view(conn, "/users/#{user.id}/edit")
|
{: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
|
describe "member linking - workflow" do
|
||||||
test "selecting member and saving links member to user", %{conn: conn} do
|
test "selecting member and saving links member to user", %{conn: conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Create unlinked member
|
# Create unlinked member
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Jane",
|
%{
|
||||||
last_name: "Smith",
|
first_name: "Jane",
|
||||||
email: "jane@example.com"
|
last_name: "Smith",
|
||||||
})
|
email: "jane@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
# Create user without member
|
# Create user without member
|
||||||
user = create_test_user(%{email: "user@example.com"})
|
user = create_test_user(%{email: "user@example.com"})
|
||||||
|
|
@ -345,22 +365,35 @@ defmodule MvWeb.UserLive.FormTest do
|
||||||
assert_redirected(view, "/users")
|
assert_redirected(view, "/users")
|
||||||
|
|
||||||
# Verify member is linked
|
# 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
|
assert updated_user.member.id == member.id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "unlinking member and saving removes member from user", %{conn: conn} do
|
test "unlinking member and saving removes member from user", %{conn: conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Create member
|
# Create member
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Bob",
|
%{
|
||||||
last_name: "Wilson",
|
first_name: "Bob",
|
||||||
email: "bob@example.com"
|
last_name: "Wilson",
|
||||||
})
|
email: "bob@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
# Create user linked to member
|
# Create user linked to member
|
||||||
user = create_test_user(%{email: "user@example.com"})
|
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")
|
{:ok, view, _html} = setup_live_view(conn, "/users/#{user.id}/edit")
|
||||||
|
|
||||||
|
|
@ -375,7 +408,15 @@ defmodule MvWeb.UserLive.FormTest do
|
||||||
assert_redirected(view, "/users")
|
assert_redirected(view, "/users")
|
||||||
|
|
||||||
# Verify member is unlinked
|
# 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)
|
assert is_nil(updated_user.member)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -407,17 +407,24 @@ defmodule MvWeb.UserLive.IndexTest do
|
||||||
|
|
||||||
describe "member linking display" do
|
describe "member linking display" do
|
||||||
test "displays linked member name in user list", %{conn: conn} do
|
test "displays linked member name in user list", %{conn: conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Create member
|
# Create member
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Mv.Membership.create_member(%{
|
Mv.Membership.create_member(
|
||||||
first_name: "Alice",
|
%{
|
||||||
last_name: "Johnson",
|
first_name: "Alice",
|
||||||
email: "alice@example.com"
|
last_name: "Johnson",
|
||||||
})
|
email: "alice@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
# Create user linked to member
|
# Create user linked to member
|
||||||
user = create_test_user(%{email: "user@example.com"})
|
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
|
# Create another user without member
|
||||||
_unlinked_user = create_test_user(%{email: "unlinked@example.com"})
|
_unlinked_user = create_test_user(%{email: "unlinked@example.com"})
|
||||||
|
|
|
||||||
|
|
@ -3,37 +3,42 @@ defmodule Mv.SeedsTest do
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
%{actor: system_actor}
|
||||||
|
end
|
||||||
|
|
||||||
describe "Seeds script" do
|
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
|
# Run the seeds script - should not raise any errors
|
||||||
assert Code.eval_file("priv/repo/seeds.exs")
|
assert Code.eval_file("priv/repo/seeds.exs")
|
||||||
|
|
||||||
# Basic smoke test: ensure some data was created
|
# Basic smoke test: ensure some data was created
|
||||||
{:ok, users} = Ash.read(Mv.Accounts.User)
|
{:ok, users} = Ash.read(Mv.Accounts.User, actor: actor)
|
||||||
{:ok, members} = Ash.read(Mv.Membership.Member)
|
{:ok, members} = Ash.read(Mv.Membership.Member, actor: actor)
|
||||||
{:ok, custom_fields} = Ash.read(Mv.Membership.CustomField)
|
{: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?(users), "Seeds should create at least one user"
|
||||||
assert not Enum.empty?(members), "Seeds should create at least one member"
|
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"
|
assert not Enum.empty?(custom_fields), "Seeds should create at least one custom field"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can be run multiple times (idempotent)" do
|
test "can be run multiple times (idempotent)", %{actor: actor} do
|
||||||
# Run seeds first time
|
# Run seeds first time
|
||||||
assert Code.eval_file("priv/repo/seeds.exs")
|
assert Code.eval_file("priv/repo/seeds.exs")
|
||||||
|
|
||||||
# Count records
|
# Count records
|
||||||
{:ok, users_count_1} = Ash.read(Mv.Accounts.User)
|
{:ok, users_count_1} = Ash.read(Mv.Accounts.User, actor: actor)
|
||||||
{:ok, members_count_1} = Ash.read(Mv.Membership.Member)
|
{:ok, members_count_1} = Ash.read(Mv.Membership.Member, actor: actor)
|
||||||
{:ok, custom_fields_count_1} = Ash.read(Mv.Membership.CustomField)
|
{:ok, custom_fields_count_1} = Ash.read(Mv.Membership.CustomField, actor: actor)
|
||||||
|
|
||||||
# Run seeds second time - should not raise errors
|
# Run seeds second time - should not raise errors
|
||||||
assert Code.eval_file("priv/repo/seeds.exs")
|
assert Code.eval_file("priv/repo/seeds.exs")
|
||||||
|
|
||||||
# Count records again - should be the same (upsert, not duplicate)
|
# Count records again - should be the same (upsert, not duplicate)
|
||||||
{:ok, users_count_2} = Ash.read(Mv.Accounts.User)
|
{:ok, users_count_2} = Ash.read(Mv.Accounts.User, actor: actor)
|
||||||
{:ok, members_count_2} = Ash.read(Mv.Membership.Member)
|
{:ok, members_count_2} = Ash.read(Mv.Membership.Member, actor: actor)
|
||||||
{:ok, custom_fields_count_2} = Ash.read(Mv.Membership.CustomField)
|
{:ok, custom_fields_count_2} = Ash.read(Mv.Membership.CustomField, actor: actor)
|
||||||
|
|
||||||
assert length(users_count_1) == length(users_count_2),
|
assert length(users_count_1) == length(users_count_2),
|
||||||
"Users count should remain same after re-running seeds"
|
"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"
|
"CustomFields count should remain same after re-running seeds"
|
||||||
end
|
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
|
# Run the seeds script
|
||||||
assert Code.eval_file("priv/repo/seeds.exs")
|
assert Code.eval_file("priv/repo/seeds.exs")
|
||||||
|
|
||||||
# Get all members
|
# 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
|
# At least one member should have no membership_fee_type_id
|
||||||
members_without_fee_type =
|
members_without_fee_type =
|
||||||
|
|
@ -60,13 +65,13 @@ defmodule Mv.SeedsTest do
|
||||||
"At least one member should have no membership fee type assigned"
|
"At least one member should have no membership fee type assigned"
|
||||||
end
|
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
|
# Run the seeds script
|
||||||
assert Code.eval_file("priv/repo/seeds.exs")
|
assert Code.eval_file("priv/repo/seeds.exs")
|
||||||
|
|
||||||
# Get all fee types and members
|
# Get all fee types and members
|
||||||
{:ok, fee_types} = Ash.read(Mv.MembershipFees.MembershipFeeType)
|
{:ok, fee_types} = Ash.read(Mv.MembershipFees.MembershipFeeType, actor: actor)
|
||||||
{:ok, members} = Ash.read(Mv.Membership.Member)
|
{:ok, members} = Ash.read(Mv.Membership.Member, actor: actor)
|
||||||
|
|
||||||
# Group members by fee type (excluding nil)
|
# Group members by fee type (excluding nil)
|
||||||
members_by_fee_type =
|
members_by_fee_type =
|
||||||
|
|
@ -83,12 +88,12 @@ defmodule Mv.SeedsTest do
|
||||||
end)
|
end)
|
||||||
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
|
# Run the seeds script
|
||||||
assert Code.eval_file("priv/repo/seeds.exs")
|
assert Code.eval_file("priv/repo/seeds.exs")
|
||||||
|
|
||||||
# Get all members with fee types
|
# 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_with_fee_types =
|
||||||
members
|
members
|
||||||
|
|
@ -104,7 +109,7 @@ defmodule Mv.SeedsTest do
|
||||||
|> Enum.flat_map(fn member ->
|
|> Enum.flat_map(fn member ->
|
||||||
Mv.MembershipFees.MembershipFeeCycle
|
Mv.MembershipFees.MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member.id)
|
|> Ash.Query.filter(member_id == ^member.id)
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
end)
|
end)
|
||||||
|> Enum.map(& &1.status)
|
|> Enum.map(& &1.status)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -115,15 +115,16 @@ defmodule MvWeb.ConnCase do
|
||||||
|
|
||||||
# Create admin role and assign it
|
# Create admin role and assign it
|
||||||
admin_role = Mv.Fixtures.role_fixture("admin")
|
admin_role = Mv.Fixtures.role_fixture("admin")
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
user
|
user
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|> Ash.update()
|
|> Ash.update(actor: system_actor)
|
||||||
|
|
||||||
# Load role for authorization
|
# 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)
|
sign_in_user_via_oidc(conn, user_with_role)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ defmodule Mv.Fixtures do
|
||||||
@doc """
|
@doc """
|
||||||
Creates a member with default or custom attributes.
|
Creates a member with default or custom attributes.
|
||||||
|
|
||||||
|
Uses system_actor for authorization to bypass permission checks in tests.
|
||||||
|
|
||||||
## Parameters
|
## Parameters
|
||||||
- `attrs` - Map or keyword list of attributes to override defaults
|
- `attrs` - Map or keyword list of attributes to override defaults
|
||||||
|
|
||||||
|
|
@ -25,13 +27,15 @@ defmodule Mv.Fixtures do
|
||||||
|
|
||||||
"""
|
"""
|
||||||
def member_fixture(attrs \\ %{}) do
|
def member_fixture(attrs \\ %{}) do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
attrs
|
attrs
|
||||||
|> Enum.into(%{
|
|> Enum.into(%{
|
||||||
first_name: "Test",
|
first_name: "Test",
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
email: "test#{System.unique_integer([:positive])}@example.com"
|
email: "test#{System.unique_integer([:positive])}@example.com"
|
||||||
})
|
})
|
||||||
|> Mv.Membership.create_member()
|
|> Mv.Membership.create_member(actor: system_actor)
|
||||||
|> case do
|
|> case do
|
||||||
{:ok, member} -> member
|
{:ok, member} -> member
|
||||||
{:error, error} -> raise "Failed to create member: #{inspect(error)}"
|
{:error, error} -> raise "Failed to create member: #{inspect(error)}"
|
||||||
|
|
@ -41,6 +45,11 @@ defmodule Mv.Fixtures do
|
||||||
@doc """
|
@doc """
|
||||||
Creates a user with default or custom attributes.
|
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
|
## Parameters
|
||||||
- `attrs` - Map or keyword list of attributes to override defaults
|
- `attrs` - Map or keyword list of attributes to override defaults
|
||||||
|
|
||||||
|
|
@ -57,11 +66,13 @@ defmodule Mv.Fixtures do
|
||||||
|
|
||||||
"""
|
"""
|
||||||
def user_fixture(attrs \\ %{}) do
|
def user_fixture(attrs \\ %{}) do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
attrs
|
attrs
|
||||||
|> Enum.into(%{
|
|> Enum.into(%{
|
||||||
email: "user#{System.unique_integer([:positive])}@example.com"
|
email: "user#{System.unique_integer([:positive])}@example.com"
|
||||||
})
|
})
|
||||||
|> Mv.Accounts.create_user()
|
|> Mv.Accounts.create_user(actor: system_actor)
|
||||||
|> case do
|
|> case do
|
||||||
{:ok, user} -> user
|
{:ok, user} -> user
|
||||||
{:error, error} -> raise "Failed to create user: #{inspect(error)}"
|
{:error, error} -> raise "Failed to create user: #{inspect(error)}"
|
||||||
|
|
@ -97,6 +108,8 @@ defmodule Mv.Fixtures do
|
||||||
@doc """
|
@doc """
|
||||||
Creates a role with a specific permission set.
|
Creates a role with a specific permission set.
|
||||||
|
|
||||||
|
Uses system_actor for authorization to bypass permission checks in tests.
|
||||||
|
|
||||||
## Parameters
|
## Parameters
|
||||||
- `permission_set_name` - The permission set name (e.g., "admin", "read_only", "normal_user", "own_data")
|
- `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
|
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])}"
|
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
|
||||||
|
|
||||||
case Mv.Authorization.create_role(%{
|
case Mv.Authorization.create_role(
|
||||||
name: role_name,
|
%{
|
||||||
description: "Test role for #{permission_set_name}",
|
name: role_name,
|
||||||
permission_set_name: permission_set_name
|
description: "Test role for #{permission_set_name}",
|
||||||
}) do
|
permission_set_name: permission_set_name
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
) do
|
||||||
{:ok, role} -> role
|
{:ok, role} -> role
|
||||||
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
|
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
|
||||||
end
|
end
|
||||||
|
|
@ -140,6 +157,8 @@ defmodule Mv.Fixtures do
|
||||||
|
|
||||||
"""
|
"""
|
||||||
def user_with_role_fixture(permission_set_name \\ "admin", user_attrs \\ %{}) 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
|
# Create role with permission set
|
||||||
role = role_fixture(permission_set_name)
|
role = role_fixture(permission_set_name)
|
||||||
|
|
||||||
|
|
@ -149,17 +168,17 @@ defmodule Mv.Fixtures do
|
||||||
|> Enum.into(%{
|
|> Enum.into(%{
|
||||||
email: "user#{System.unique_integer([:positive])}@example.com"
|
email: "user#{System.unique_integer([:positive])}@example.com"
|
||||||
})
|
})
|
||||||
|> Mv.Accounts.create_user()
|
|> Mv.Accounts.create_user(actor: system_actor)
|
||||||
|
|
||||||
# Assign role to user
|
# Assign role to user
|
||||||
{:ok, user} =
|
{:ok, user} =
|
||||||
user
|
user
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|
|> 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!)
|
# 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
|
user_with_role
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue