Compare commits

...

30 commits

Author SHA1 Message Date
4473cfd372 Tests: use code interface for Member create/update (actor propagation)
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/production Build is passing
2026-01-29 16:10:12 +01:00
5a2f035ecc CustomField policies: actor required, no system-actor fallback, error handling
- list_required_custom_fields: require actor (two clauses, no default)
- Member validation: use context.actor only, differentiate Forbidden vs transient errors
- stream_custom_fields: log + send flash on error instead of returning []
- GlobalSettingsLive: handle_info for custom_fields_load_error, put_flash
- Seeds: use Membership.update_member with actor, format
2026-01-29 16:10:12 +01:00
c9431caabe Add gettext strings for custom field load error and not authorized 2026-01-29 16:10:12 +01:00
9a7622ebed fix: pass actor to CustomFieldLive.FormComponent for save
IndexComponent now passes actor to FormComponent; FormComponent uses
assigns[:actor] instead of current_actor(socket). Add test that submits
new custom field form on settings page.
2026-01-29 16:10:12 +01:00
1d17c4f2dd fix: CustomField policies, no system-actor fallback, guidelines
- Tests and UI pass actor for CustomField create/read/destroy; seeds use actor
- Member required-custom-fields validation uses context.actor only (no fallback)
- CODE_GUIDELINES: add rule forbidding system-actor fallbacks
2026-01-29 16:10:12 +01:00
36b5d5880b Add CustomField resource policies and tests
- Add policies block with HasPermission for read/create/update/destroy
- Add authorizers: [Ash.Policy.Authorizer] to CustomField resource
- Add custom_field_policies_test.exs (read all roles, write admin only)
- Fix CustomField path in roles-and-permissions doc (lib/membership)
2026-01-29 16:10:12 +01:00
ca88a230b9 Merge pull request 'Minor test refactoring to improve on performance closes #383' (#384) from test-performance-optimization into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #384
2026-01-29 15:43:59 +01:00
709cf010c6
docs: consolidate test performance docs
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-01-29 15:34:14 +01:00
9b314a9806
fix: credo error
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-01-29 15:26:45 +01:00
b4adf63e83
feix: optimize queries for groups
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-29 15:22:40 +01:00
124ab295a6
fix: select all checkbox handling 2026-01-29 15:14:36 +01:00
bb7e3cbe77
fix: make sure all tests run
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-29 14:49:39 +01:00
dddad69e88
chore: remove pr trigger again
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is failing
2026-01-29 14:42:47 +01:00
0a1b52d978
test: fix tests
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone/pr Build is failing
2026-01-29 14:39:31 +01:00
17974d7a12
chore: change pr merge workflow
Some checks reported errors
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build was killed
2026-01-29 14:30:09 +01:00
1019914d50
docs: update coding guidelines
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-29 12:59:35 +01:00
0b29fbbd21
test: restore removed tests including optimizations 2026-01-29 12:59:06 +01:00
25da6a6820
chore: update drone nightly pipeline
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-28 15:04:24 +01:00
3f0dc868c9
chore: disable test performance output again
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-28 14:54:59 +01:00
c3ad8894b0
refactor: implement more review comments
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-28 14:47:30 +01:00
ea3bdcaa65
refactor: apply review comments
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-28 14:42:16 +01:00
050ca4a13c
test: move slow and less critical tests to nightly suite
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-28 14:34:05 +01:00
eb2b2436be
docs: add performance analysis on policy tests 2026-01-28 14:01:41 +01:00
91f8bb03bc
refactor: remove tests against basic framework functionalities
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-28 13:46:18 +01:00
15d328afbf
test: optimize single test and update docs
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-28 13:33:39 +01:00
6efad280bd
refactor: apply review comments
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-28 12:36:19 +01:00
858a0fc0d0
chore: allow manual nightly-tests pipeline run
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-28 12:07:51 +01:00
67e06e12ce
refactor: move slow performance tests to extra test suite
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-28 12:00:32 +01:00
fce01ddf83
style: fix formatting
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-28 11:32:46 +01:00
f9403c1da9
refactor: improve seeds tests performance by reducing complexity
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-28 11:31:31 +01:00
66 changed files with 2681 additions and 1329 deletions

View file

@ -1,6 +1,6 @@
kind: pipeline
type: docker
name: check
name: check-fast
services:
- name: postgres
@ -72,7 +72,7 @@ steps:
echo "Postgres did not become available, aborting."
exit 1
- name: test
- name: test-fast
image: docker.io/library/elixir:1.18.3-otp-27
environment:
MIX_ENV: test
@ -83,7 +83,114 @@ steps:
- mix local.hex --force
# Fetch dependencies
- mix deps.get
# Run tests
# Run fast tests (excludes slow/performance and UI tests)
- mix test --exclude slow --exclude ui
- name: rebuild-cache
image: drillster/drone-volume-cache
settings:
rebuild: true
mount:
- ./deps
- ./_build
volumes:
- name: cache
path: /cache
volumes:
- name: cache
host:
path: /tmp/drone_cache
---
kind: pipeline
type: docker
name: check-full
services:
- name: postgres
image: docker.io/library/postgres:18.1
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
trigger:
event:
- promote
target:
- production
steps:
- name: compute cache key
image: docker.io/library/elixir:1.18.3-otp-27
commands:
- mix_lock_hash=$(sha256sum mix.lock | cut -d ' ' -f 1)
- echo "$DRONE_REPO_OWNER/$DRONE_REPO_NAME/$mix_lock_hash" >> .cache_key
# Print cache key for debugging
- cat .cache_key
- name: restore-cache
image: drillster/drone-volume-cache
settings:
restore: true
mount:
- ./deps
- ./_build
ttl: 30
volumes:
- name: cache
path: /cache
- name: lint
image: docker.io/library/elixir:1.18.3-otp-27
commands:
# Install hex package manager
- mix local.hex --force
# Fetch dependencies
- mix deps.get
# Check for compilation errors & warnings
- mix compile --warnings-as-errors
# Check formatting
- mix format --check-formatted
# Security checks
- mix sobelow --config
# Check dependencies for known vulnerabilities
- mix deps.audit
# Check for dependencies that are not maintained anymore
- mix hex.audit
# Provide hints for improving code quality
- mix credo
# Check that translations are up to date
- mix gettext.extract --check-up-to-date
- name: wait_for_postgres
image: docker.io/library/postgres:18.1
commands:
# Wait for postgres to become available
- |
for i in {1..20}; do
if pg_isready -h postgres -U postgres; then
exit 0
else
true
fi
sleep 2
done
echo "Postgres did not become available, aborting."
exit 1
- name: test-all
image: docker.io/library/elixir:1.18.3-otp-27
environment:
MIX_ENV: test
TEST_POSTGRES_HOST: postgres
TEST_POSTGRES_PORT: 5432
commands:
# Install hex package manager
- mix local.hex --force
# Fetch dependencies
- mix deps.get
# Run all tests (including slow/performance and UI tests)
- mix test
- name: rebuild-cache
@ -147,7 +254,7 @@ steps:
- push
depends_on:
- check
- check-fast
---
kind: pipeline

48
.forgejo/README.md Normal file
View file

@ -0,0 +1,48 @@
# Forgejo Configuration
This directory contains configuration files for Forgejo (self-hosted Git service).
## Pull Request Template
The `pull_request_template.md` is automatically loaded when creating a new Pull Request. It provides a checklist and instructions for the PR workflow, including how to run the full test suite before merging.
## Branch Protection Setup
To enforce the full test suite before merging to `main`, configure branch protection in Forgejo:
### Steps:
1. Go to **Repository Settings****Branches** → **Protected Branches**
2. Add a new rule for branch: `main`
3. Configure the following settings:
- ☑️ **Enable Branch Protection**
- ☑️ **Require status checks to pass before merging**
- Add required check: `check-full`
- ☐ **Require approvals** (optional, based on team preference)
- ☑️ **Block if there are outstanding requests** (optional)
### What this does:
- The **"Merge"** button in PRs will only be enabled after `check-full` passes
- `check-full` is triggered by **promoting** a build in Drone CI (see PR template)
- This ensures all tests (including slow and UI tests) run before merging
## Workflow
1. **Create PR** → Fast test suite (`check-fast`) runs automatically
2. **Development** → Fast tests run on every push for quick feedback
3. **Ready to merge:**
- Remove `WIP:` from PR title
- Go to Drone CI and **promote** the build to `production`
- This triggers `check-full` (full test suite)
4. **After full tests pass** → Merge button becomes available
5. **Merge to main** → Container is built and published
## Secrets Required
Make sure the following secrets are configured in Drone CI:
- `DRONE_REGISTRY_USERNAME` - For container registry
- `DRONE_REGISTRY_TOKEN` - For container registry
- `RENOVATE_TOKEN` - For Renovate bot
- `GITHUB_COM_TOKEN` - For Renovate bot (GitHub dependencies)

View file

@ -683,6 +683,13 @@ end
- Falls back to admin user from seeds if system user doesn't exist
- Should NEVER be used for user-initiated actions (only systemic operations)
**DO NOT use system actor as a fallback:**
- **Never** fall back to `Mv.Helpers.SystemActor.get_system_actor()` when an actor is missing or nil (e.g. in validations, changes, or when reading from context).
- Fallbacks hide bugs (callers forget to pass actor) and can cause privilege escalation (unauthenticated or low-privilege paths run with system rights).
- If no actor is available, fail explicitly (validation error, Forbidden, or clear error message). Fix the caller to pass the correct actor instead of adding a fallback.
- Use system actor only where the operation is **explicitly** a systemic operation (see list above); never as a "safety net" when actor is absent.
**User Mode vs System Mode:**
- **User Mode**: User-initiated actions use the actual user actor, policies are enforced
@ -1654,10 +1661,87 @@ end
- Mock external services
- Use fixtures efficiently
**Performance Tests:**
Performance tests that explicitly validate performance characteristics should be tagged with `@tag :slow` or `@describetag :slow` to exclude them from standard test runs. This improves developer feedback loops while maintaining comprehensive coverage.
**When to Tag as `:slow`:**
- Tests that explicitly measure execution time or validate performance characteristics
- Tests that use large datasets (e.g., 50+ records) to test scalability
- Tests that validate query optimization (N+1 prevention, index usage)
**Tagging Guidelines:**
- Use `@tag :slow` for individual tests
- Use `@describetag :slow` for entire describe blocks (not `@moduletag`, as it affects all tests in the module)
- Performance tests should include measurable assertions (query counts, timing with tolerance, etc.)
**UI Tests:**
UI tests that validate basic HTML rendering, Phoenix LiveView navigation, or framework functionality (Gettext translations, form elements, UI state changes) should be tagged with `@tag :ui` or `@describetag :ui` to exclude them from fast CI runs. Use `@tag :ui` for individual tests and `@describetag :ui` for describe blocks. UI tests can be consolidated when they test similar elements (e.g., multiple translation tests combined into one). Do not tag business logic tests (e.g., "can delete a user"), validation tests, or data persistence tests as `:ui`.
**Running Tests:**
```bash
# Fast tests only (excludes slow and UI tests)
mix test --exclude slow --exclude ui
# Or use the Justfile command:
just test-fast
# UI tests only
mix test --only ui
# Or use the Justfile command:
just ui
# Performance tests only
mix test --only slow
# Or use the Justfile command:
just slow
# All tests (including slow and UI tests)
mix test
# Or use the Justfile command:
just test
# Or use the Justfile command:
just test-all
```
**Test Organization Best Practices:**
- **Fast Tests (Standard CI):** Business logic, validations, data persistence, edge cases
- **UI Tests (Full Test Suite):** Basic HTML rendering, navigation, translations, UI state
- **Performance Tests (Full Test Suite):** Query optimization, large datasets, timing assertions
This organization ensures fast feedback in standard CI while maintaining comprehensive coverage via promotion before merge.
---
## 5. Security Guidelines
### 5.0 No system-actor fallbacks (mandatory)
**Do not use the system actor as a fallback when an actor is missing.**
Examples of forbidden patterns:
```elixir
# ❌ FORBIDDEN - Fallback to system actor when actor is nil
actor = Map.get(changeset.context, :actor) || Mv.Helpers.SystemActor.get_system_actor()
# ❌ FORBIDDEN - "Safety" fallback in validations, changes, or helpers
actor = opts[:actor] || Mv.Helpers.SystemActor.get_system_actor()
# ❌ FORBIDDEN - Default actor in function options
def list_something(opts \\ []) do
actor = Keyword.get(opts, :actor) || Mv.Helpers.SystemActor.get_system_actor()
# ...
end
```
**Why:** Fallbacks hide missing-actor bugs and can lead to privilege escalation (e.g. a request without actor would run with system privileges). Always require the caller to pass the actor for user-facing or context-dependent operations; if none is available, return an error or fail validation instead of using the system actor.
**Allowed:** Use the system actor only where the operation is **by design** a systemic operation (e.g. email sync, seeds, test fixtures, background jobs) and you explicitly call `SystemActor.get_system_actor()` at that call site—never as a fallback when `actor` is nil or absent.
### 5.1 Authentication & Authorization
**Use AshAuthentication:**

View file

@ -22,7 +22,7 @@ seed-database:
start-database:
docker compose up -d
ci-dev: lint audit test
ci-dev: lint audit test-fast
gettext:
mix gettext.extract
@ -41,9 +41,30 @@ audit:
mix deps.audit
mix hex.audit
# Run all tests
test *args: install-dependencies
mix test {{args}}
# Run only fast tests (excludes slow/performance and UI tests)
test-fast *args: install-dependencies
mix test --exclude slow --exclude ui {{args}}
# Run only UI tests
ui *args: install-dependencies
mix test --only ui {{args}}
# Run only slow/performance tests
slow *args: install-dependencies
mix test --only slow {{args}}
# Run only slow/performance tests (alias for consistency)
test-slow *args: install-dependencies
mix test --only slow {{args}}
# Run all tests (fast + slow + ui)
test-all *args: install-dependencies
mix test {{args}}
format:
mix format

View file

@ -1101,28 +1101,23 @@ end
### CustomField Resource Policies
**Location:** `lib/mv/membership/custom_field.ex`
**Location:** `lib/membership/custom_field.ex`
**No Special Cases:** All users can read, only admin can write.
```elixir
defmodule Mv.Membership.CustomField do
use Ash.Resource, ...
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
policies do
# All authenticated users can read custom fields (needed for forms)
# Write operations are admin-only
policy action_type([:read, :create, :update, :destroy]) do
description "Check permissions from user's role"
authorize_if Mv.Authorization.Checks.HasPermission
end
# DEFAULT: Forbid
policy action_type([:read, :create, :update, :destroy]) do
forbid_if always()
end
end
# ...
end
```

View file

@ -0,0 +1,877 @@
# Test Performance Optimization
**Last Updated:** 2026-01-28
**Status:** ✅ Active optimization program
---
## Executive Summary
This document provides a comprehensive overview of test performance optimizations, risk assessments, and future opportunities. The test suite execution time has been reduced through systematic analysis and targeted optimizations.
### Current Performance Metrics
| Metric | Value |
|--------|-------|
| **Total Execution Time** (without `:slow` tests) | ~368 seconds (~6.1 minutes) |
| **Total Tests** | 1,336 tests (+ 25 doctests) |
| **Async Execution** | 163.5 seconds |
| **Sync Execution** | 281.5 seconds |
| **Slow Tests Excluded** | 25 tests (tagged with `@tag :slow`) |
| **Top 50 Slowest Tests** | 121.9 seconds (27.4% of total time) |
### Optimization Impact Summary
| Optimization | Tests Affected | Time Saved | Status |
|--------------|----------------|------------|--------|
| Seeds tests reduction | 13 → 4 tests | ~10-16s | ✅ Completed |
| Performance tests tagging | 9 tests | ~3-4s per run | ✅ Completed |
| Critical test query filtering | 1 test | ~8-10s | ✅ Completed |
| Full test suite via promotion | 25 tests | ~77s per run | ✅ Completed |
| **Total Saved** | | **~98-107s** | |
---
## Completed Optimizations
### 1. Seeds Test Suite Optimization
**Date:** 2026-01-28
**Status:** ✅ Completed
#### What Changed
- **Reduced test count:** From 13 tests to 4 tests (69% reduction)
- **Reduced seeds executions:** From 8-10 times to 5 times per test run
- **Execution time:** From 24-30 seconds to 13-17 seconds
- **Time saved:** ~10-16 seconds per test run (40-50% faster)
#### Removed Tests (9 tests)
Tests were removed because their functionality is covered by domain-specific test suites:
1. `"at least one member has no membership fee type assigned"` → Covered by `membership_fees/*_test.exs`
2. `"each membership fee type has at least one member"` → Covered by `membership_fees/*_test.exs`
3. `"members with fee types have cycles with various statuses"` → Covered by `cycle_generator_test.exs`
4. `"creates all 5 authorization roles with correct permission sets"` → Covered by `authorization/*_test.exs`
5. `"all roles have valid permission_set_names"` → Covered by `authorization/permission_sets_test.exs`
6. `"does not change role of users who already have a role"` → Merged into idempotency test
7. `"role creation is idempotent"` (detailed) → Merged into general idempotency test
#### Retained Tests (4 tests)
Critical deployment requirements are still covered:
1. ✅ **Smoke Test:** Seeds run successfully and create basic data
2. ✅ **Idempotency Test:** Seeds can be run multiple times without duplicating data
3. ✅ **Admin Bootstrap:** Admin user exists with Admin role (critical for initial access)
4. ✅ **System Role Bootstrap:** Mitglied system role exists (critical for user registration)
#### Risk Assessment
| Removed Test Category | Alternative Coverage | Risk Level |
|----------------------|---------------------|------------|
| Member/fee type distribution | `membership_fees/*_test.exs` | ⚠️ Low |
| Cycle status variations | `cycle_generator_test.exs` | ⚠️ Low |
| Detailed role configs | `authorization/*_test.exs` | ⚠️ Very Low |
| Permission set validation | `permission_sets_test.exs` | ⚠️ Very Low |
**Overall Risk:** ⚠️ **Low** - All removed tests have equivalent or better coverage in domain-specific test suites.
---
### 2. Full Test Suite via Promotion (`@tag :slow`)
**Date:** 2026-01-28
**Status:** ✅ Completed
#### What Changed
Tests with **low risk** and **execution time >1 second** are now tagged with `@tag :slow` and excluded from standard test runs. These tests are important but not critical for every commit and are run via promotion before merging to `main`.
#### Tagging Criteria
**Tagged as `@tag :slow` when:**
- ✅ Test execution time >1 second
- ✅ Low risk (not critical for catching regressions in core business logic)
- ✅ UI/Display tests (formatting, rendering)
- ✅ Workflow detail tests (not core functionality)
- ✅ Edge cases with large datasets
**NOT tagged when:**
- ❌ Core CRUD operations (Member/User Create/Update/Destroy)
- ❌ Basic Authentication/Authorization
- ❌ Critical Bootstrap (Admin user, system roles)
- ❌ Email Synchronization
- ❌ Representative tests per Permission Set + Action
#### Identified Tests for Full Test Suite (25 tests)
**1. Seeds Tests (2 tests) - 18.1s**
- `"runs successfully and creates basic data"` (9.0s)
- `"is idempotent when run multiple times"` (9.1s)
- **Note:** Critical bootstrap tests remain in fast suite
**2. UserLive.ShowTest (3 tests) - 10.8s**
- `"mounts successfully with valid user ID"` (4.2s)
- `"displays linked member when present"` (2.4s)
- `"redirects to user list when viewing system actor user"` (4.2s)
**3. UserLive.IndexTest (5 tests) - 25.0s**
- `"displays users in a table"` (1.0s)
- `"initially sorts by email ascending"` (2.2s)
- `"can sort email descending by clicking sort button"` (3.4s)
- `"select all automatically checks when all individual users are selected"` (2.0s)
- `"displays linked member name in user list"` (1.9s)
**4. MemberLive.IndexCustomFieldsDisplayTest (3 tests) - 4.9s**
- `"displays custom field with show_in_overview: true"` (1.6s)
- `"formats date custom field values correctly"` (1.5s)
- `"formats email custom field values correctly"` (1.8s)
**5. MemberLive.IndexCustomFieldsEdgeCasesTest (3 tests) - 3.6s**
- `"displays custom field column even when no members have values"` (1.1s)
- `"displays very long custom field values correctly"` (1.4s)
- `"handles multiple custom fields with show_in_overview correctly"` (1.2s)
**6. RoleLive Tests (7 tests) - 7.7s**
- `role_live_test.exs`: `"mounts successfully"` (1.5s), `"deletes non-system role"` (2.1s)
- `role_live/show_test.exs`: 5 tests >1s (mount, display, navigation)
**7. MemberAvailableForLinkingTest (1 test) - 1.5s**
- `"limits results to 10 members even when more exist"` (1.5s)
**8. Performance Tests (1 test) - 3.8s**
- `"boolean filter performance with 150 members"` (3.8s)
**Total:** 25 tests, ~77 seconds saved
#### Execution Commands
**Fast Tests (Default):**
```bash
just test-fast
# or
mix test --exclude slow
```
**Slow Tests Only:**
```bash
just test-slow
# or
mix test --only slow
```
**All Tests:**
```bash
just test
# or
mix test
```
#### CI/CD Integration
- **Standard CI (`check-fast`):** Runs `mix test --exclude slow --exclude ui` for faster feedback loops (~6 minutes)
- **Full Test Suite (`check-full`):** Triggered via promotion before merge, executes `mix test` (all tests, including slow and UI) for comprehensive coverage (~7.4 minutes)
- **Pre-Merge:** Full test suite (`mix test`) runs via promotion before merging to main
- **Manual Execution:** Promote build to `production` in Drone CI to trigger full test suite
#### Risk Assessment
**Risk Level:** ✅ **Very Low**
- All tagged tests have **low risk** - they don't catch critical regressions
- Core functionality remains tested (CRUD, Auth, Bootstrap)
- Standard test runs are faster (~6 minutes vs ~7.4 minutes)
- Full test suite runs via promotion before merge ensures comprehensive coverage
- No functionality is lost, only execution timing changed
**Critical Tests Remain in Fast Suite:**
- Core CRUD operations (Member/User Create/Update/Destroy)
- Basic Authentication/Authorization
- Critical Bootstrap (Admin user, system roles)
- Email Synchronization
- Representative Policy tests (one per Permission Set + Action)
---
### 3. Critical Test Optimization
**Date:** 2026-01-28
**Status:** ✅ Completed
#### Problem Identified
The test `test respects show_in_overview config` was the slowest test in the suite:
- **Isolated execution:** 4.8 seconds
- **In full test run:** 14.7 seconds
- **Difference:** 9.9 seconds (test isolation issue)
#### Root Cause
The test loaded **all members** from the database, not just the 2 members from the test setup. In full test runs, many members from other tests were present in the database, significantly slowing down the query.
#### Solution Implemented
**Query Filtering:** Added search query parameter to filter to only the expected member.
**Code Change:**
```elixir
# Before:
{:ok, _view, html} = live(conn, "/members")
# After:
{:ok, _view, html} = live(conn, "/members?query=Alice")
```
#### Results
| Execution | Before | After | Improvement |
|-----------|--------|-------|-------------|
| **Isolated** | 4.8s | 1.1s | **-77%** (3.7s saved) |
| **In Module** | 4.2s | 0.4s | **-90%** (3.8s saved) |
| **Expected in Full Run** | 14.7s | ~4-6s | **-65% to -73%** (8-10s saved) |
#### Risk Assessment
**Risk Level:** ✅ **Very Low**
- Test functionality unchanged - only loads expected data
- All assertions still pass
- Test is now faster and more isolated
- No impact on test coverage
---
### 3. Full Test Suite Analysis and Categorization
**Date:** 2026-01-28
**Status:** ✅ Completed
#### Analysis Methodology
A comprehensive analysis was performed to identify tests suitable for the full test suite (via promotion) based on:
- **Execution time:** Tests taking >1 second
- **Risk assessment:** Tests that don't catch critical regressions
- **Test category:** UI/Display, workflow details, edge cases
#### Test Categorization
**🔴 CRITICAL - Must Stay in Fast Suite:**
- Core Business Logic (Member/User CRUD)
- Authentication & Authorization Basics
- Critical Bootstrap (Admin user, system roles)
- Email Synchronization
- Representative Policy Tests (one per Permission Set + Action)
**🟡 LOW RISK - Moved to Full Test Suite (via Promotion):**
- Seeds Tests (non-critical: smoke test, idempotency)
- LiveView Display/Formatting Tests
- UserLive.ShowTest (core functionality covered by Index/Form)
- UserLive.IndexTest UI Features (sorting, checkboxes, navigation)
- RoleLive Tests (role management, not core authorization)
- MemberLive Custom Fields Display Tests
- Edge Cases with Large Datasets
#### Risk Assessment Summary
| Category | Tests | Time Saved | Risk Level | Rationale |
|----------|-------|------------|------------|-----------|
| Seeds (non-critical) | 2 | 18.1s | ⚠️ Low | Critical bootstrap tests remain |
| UserLive.ShowTest | 3 | 10.8s | ⚠️ Low | Core CRUD covered by Index/Form |
| UserLive.IndexTest (UI) | 5 | 25.0s | ⚠️ Low | UI features, not core functionality |
| Custom Fields Display | 6 | 8.5s | ⚠️ Low | Formatting tests, visible in code review |
| RoleLive Tests | 7 | 7.7s | ⚠️ Low | Role management, not authorization |
| Edge Cases | 1 | 1.5s | ⚠️ Low | Edge case, not critical path |
| Performance Tests | 1 | 3.8s | ✅ Very Low | Explicit performance validation |
| **Total** | **25** | **~77s** | **⚠️ Low** | |
**Overall Risk:** ⚠️ **Low** - All moved tests have low risk and don't catch critical regressions. Core functionality remains fully tested.
#### Tests Excluded from Full Test Suite
The following tests were **NOT** moved to full test suite (via promotion) despite being slow:
- **Policy Tests:** Medium risk - kept in fast suite (representative tests remain)
- **UserLive.FormTest:** Medium risk - core CRUD functionality
- **Tests <1s:** Don't meet execution time threshold
- **Critical Bootstrap Tests:** High risk - deployment critical
---
## Current Performance Analysis
### Top 20 Slowest Tests (without `:slow`)
After implementing the full test suite via promotion, the remaining slowest tests are:
| Rank | Test | File | Time | Category |
|------|------|------|------|----------|
| 1 | `test Critical bootstrap invariants Mitglied system role exists` | `seeds_test.exs` | 6.7s | Critical Bootstrap |
| 2 | `test Critical bootstrap invariants Admin user has Admin role` | `seeds_test.exs` | 5.0s | Critical Bootstrap |
| 3 | `test normal_user permission set can read own user record` | `user_policies_test.exs` | 2.6s | Policy Test |
| 4 | `test normal_user permission set can create member` | `member_policies_test.exs` | 2.5s | Policy Test |
| 5-20 | Various Policy and LiveView tests | Multiple files | 1.5-2.4s each | Policy/LiveView |
**Total Top 20:** ~44 seconds (12% of total time without `:slow`)
**Note:** Many previously slow tests (UserLive.IndexTest, UserLive.ShowTest, Display/Formatting tests) are now tagged with `@tag :slow` and excluded from standard runs.
### Performance Hotspots Identified
#### 1. Seeds Tests (~16.2s for 4 tests)
**Status:** ✅ Optimized (reduced from 13 tests)
**Remaining Optimization Potential:** 3-5 seconds
**Opportunities:**
- Settings update could potentially be moved to `setup_all` (if sandbox allows)
- Seeds execution could be further optimized (less data in test mode)
- Idempotency test could be optimized (only 1x seeds instead of 2x)
#### 2. User LiveView Tests (~35.5s for 10 tests)
**Status:** ⏳ Identified for optimization
**Optimization Potential:** 15-20 seconds
**Files:**
- `test/mv_web/user_live/index_test.exs` (3 tests, ~10.2s)
- `test/mv_web/user_live/form_test.exs` (4 tests, ~15.0s)
- `test/mv_web/user_live/show_test.exs` (3 tests, ~10.3s)
**Patterns:**
- Many tests create user/member data
- LiveView mounts are expensive
- Form submissions with validations are slow
**Recommended Actions:**
- Move shared fixtures to `setup_all`
- Reduce test data volume (3-5 users instead of 10+)
- Optimize setup patterns for recurring patterns
#### 3. Policy Tests (~8.7s for 3 tests)
**Status:** ⏳ Identified for optimization
**Optimization Potential:** 5-8 seconds
**Files:**
- `test/mv/membership/member_policies_test.exs` (2 tests, ~6.1s)
- `test/mv/accounts/user_policies_test.exs` (1 test, ~2.6s)
**Pattern:**
- Each test creates new roles/users/members
- Roles are identical across tests
**Recommended Actions:**
- Create roles in `setup_all` (shared across tests)
- Reuse common fixtures
- Maintain test isolation while optimizing setup
---
## Future Optimization Opportunities
### Priority 1: User LiveView Tests Optimization
**Estimated Savings:** 14-22 seconds
**Status:** 📋 Analysis Complete - Ready for Implementation
#### Analysis Summary
Analysis of User LiveView tests identified significant optimization opportunities:
- **Framework functionality over-testing:** ~30 tests test Phoenix/Ash/Gettext core features
- **Redundant test data creation:** Each test creates users/members independently
- **Missing shared fixtures:** No `setup_all` usage for common data
#### Current Performance
**Top 20 Slowest Tests (User LiveView):**
- `index_test.exs`: ~10.2s for 3 tests in Top 20
- `form_test.exs`: ~15.0s for 4 tests in Top 20
- `show_test.exs`: ~10.3s for 3 tests in Top 20
- **Total:** ~35.5 seconds for User LiveView tests
#### Optimization Opportunities
**1. Remove Framework Functionality Tests (~30 tests, 8-12s saved)**
- Remove translation tests (Gettext framework)
- Remove navigation tests (Phoenix LiveView framework)
- Remove validation tests (Ash framework)
- Remove basic HTML rendering tests (consolidate into smoke test)
- Remove password storage tests (AshAuthentication framework)
**2. Implement Shared Fixtures (3-5s saved)**
- Use `setup_all` for common test data in `index_test.exs` and `show_test.exs`
- Share users for sorting/checkbox tests
- Share common users/members across tests
- **Note:** `form_test.exs` uses `async: false`, preventing `setup_all` usage
**3. Consolidate Redundant Tests (~10 tests → 3-4 tests, 2-3s saved)**
- Merge basic display tests into smoke test
- Merge navigation tests into integration test
- Reduce sorting tests to 1 integration test
**4. Optimize Test Data Volume (1-2s saved)**
- Use minimum required data (2 users for sorting, 2 for checkboxes)
- Share data across tests via `setup_all`
#### Tests to Keep (Business Logic)
**Index Tests:**
- `initially sorts by email ascending` - Tests default sort
- `can sort email descending by clicking sort button` - Tests sort functionality
- `select all automatically checks when all individual users are selected` - Business logic
- `does not show system actor user in list` - Business rule
- `displays linked member name in user list` - Business logic
- Edge case tests
**Form Tests:**
- `creates user without password` - Business logic
- `creates user with password when enabled` - Business logic
- `admin sets new password for user` - Business logic
- `selecting member and saving links member to user` - Business logic
- Member linking/unlinking workflow tests
**Show Tests:**
- `displays password authentication status` - Business logic
- `displays linked member when present` - Business logic
- `redirects to user list when viewing system actor user` - Business rule
#### Implementation Plan
**Phase 1: Remove Framework Tests (1-2 hours, ⚠️ Very Low Risk)**
- Remove translation, navigation, validation, and basic HTML rendering tests
- Consolidate remaining display tests into smoke test
**Phase 2: Implement Shared Fixtures (2-3 hours, ⚠️ Low Risk)**
- Add `setup_all` to `index_test.exs` and `show_test.exs`
- Update tests to use shared fixtures
- Verify test isolation maintained
**Phase 3: Consolidate Tests (1-2 hours, ⚠️ Very Low Risk)**
- Merge basic display tests into smoke test
- Merge navigation tests into integration test
- Reduce sorting tests to 1 integration test
**Risk Assessment:** ⚠️ **Low**
- Framework functionality is tested by framework maintainers
- Business logic tests remain intact
- Shared fixtures maintain test isolation
- Consolidation preserves coverage
### Priority 2: Policy Tests Optimization
**Estimated Savings:** 5.5-9 seconds
**Status:** 📋 Analysis Complete - Ready for Decision
#### Analysis Summary
Analysis of policy tests identified significant optimization opportunities:
- **Redundant fixture creation:** Roles and users created repeatedly across tests
- **Framework functionality over-testing:** Many tests verify Ash policy framework behavior
- **Test duplication:** Similar tests across different permission sets
#### Current Performance
**Policy Test Files Performance:**
- `member_policies_test.exs`: 24 tests, ~66s (top 20)
- `user_policies_test.exs`: 30 tests, ~66s (top 20)
- `custom_field_value_policies_test.exs`: 20 tests, ~66s (top 20)
- **Total:** 74 tests, ~152s total
**Top 20 Slowest Policy Tests:** ~66 seconds
#### Framework vs. Business Logic Analysis
**Framework Functionality (Should NOT Test):**
- Policy evaluation (how Ash evaluates policies)
- Permission lookup (how Ash looks up permissions)
- Scope filtering (how Ash applies scope filters)
- Auto-filter behavior (how Ash auto-filters queries)
- Forbidden vs NotFound (how Ash returns errors)
**Business Logic (Should Test):**
- Permission set definitions (what permissions each role has)
- Scope definitions (what scopes each permission set uses)
- Special cases (custom business rules)
- Permission set behavior (how our permission sets differ)
#### Optimization Opportunities
**1. Remove Framework Functionality Tests (~22-34 tests, 3-4s saved)**
- Remove "cannot" tests that verify error types (Forbidden, NotFound)
- Remove tests that verify auto-filter behavior (framework)
- Remove tests that verify permission evaluation (framework)
- **Risk:** ⚠️ Very Low - Framework functionality is tested by Ash maintainers
**2. Consolidate Redundant Tests (~6-8 tests → 2-3 tests, 1-2s saved)**
- Merge similar tests across permission sets
- Create integration tests that cover multiple permission sets
- **Risk:** ⚠️ Low - Same coverage, fewer tests
**3. Share Admin User Across Describe Blocks (1-2s saved)**
- Create admin user once in module-level `setup`
- Reuse admin user in helper functions
- **Note:** `async: false` prevents `setup_all`, but module-level `setup` works
- **Risk:** ⚠️ Low - Admin user is read-only in tests, safe to share
**4. Reduce Test Data Volume (0.5-1s saved)**
- Use minimum required data
- Share fixtures where possible
- **Risk:** ⚠️ Very Low - Still tests same functionality
#### Test Classification Summary
**Tests to Remove (Framework):**
- `member_policies_test.exs`: ~10 tests (cannot create/destroy/update, auto-filter tests)
- `user_policies_test.exs`: ~16 tests (cannot read/update/create/destroy, auto-filter tests)
- `custom_field_value_policies_test.exs`: ~8 tests (similar "cannot" tests)
**Tests to Consolidate (Redundant):**
- `user_policies_test.exs`: 6 tests → 2 tests (can read/update own user record)
**Tests to Keep (Business Logic):**
- All "can" tests that verify permission set behavior
- Special case tests (e.g., "user can always READ linked member")
- AshAuthentication bypass tests (our integration)
#### Implementation Plan
**Phase 1: Remove Framework Tests (1-2 hours, ⚠️ Very Low Risk)**
- Identify all "cannot" tests that verify error types
- Remove tests that verify Ash auto-filter behavior
- Remove tests that verify permission evaluation (framework)
**Phase 2: Consolidate Redundant Tests (1-2 hours, ⚠️ Low Risk)**
- Identify similar tests across permission sets
- Create integration tests that cover multiple permission sets
- Remove redundant individual tests
**Phase 3: Share Admin User (1-2 hours, ⚠️ Low Risk)**
- Add module-level `setup` to create admin user
- Update helper functions to accept admin user parameter
- Update all `setup` blocks to use shared admin user
**Risk Assessment:** ⚠️ **Low**
- Framework functionality is tested by Ash maintainers
- Business logic tests remain intact
- Admin user sharing maintains test isolation (read-only)
- Consolidation preserves coverage
### Priority 3: Seeds Tests Further Optimization
**Estimated Savings:** 3-5 seconds
**Actions:**
1. Investigate if settings update can be moved to `setup_all`
2. Introduce seeds mode for tests (less data in test mode)
3. Optimize idempotency test (only 1x seeds instead of 2x)
**Risk Assessment:** ⚠️ **Low to Medium**
- Sandbox limitations may prevent `setup_all` usage
- Seeds mode would require careful implementation
- Idempotency test optimization needs to maintain test validity
### Priority 4: Additional Test Isolation Improvements
**Estimated Savings:** Variable (depends on specific tests)
**Actions:**
1. Review tests that load all records (similar to the critical test fix)
2. Add query filters where appropriate
3. Ensure proper test isolation in async tests
**Risk Assessment:** ⚠️ **Very Low**
- Similar to the critical test optimization (proven approach)
- Improves test isolation and reliability
---
## Estimated Total Optimization Potential
| Priority | Optimization | Estimated Savings |
|----------|-------------|-------------------|
| 1 | User LiveView Tests | 14-22s |
| 2 | Policy Tests | 5.5-9s |
| 3 | Seeds Tests Further | 3-5s |
| 4 | Additional Isolation | Variable |
| **Total Potential** | | **22.5-36 seconds** |
**Projected Final Time:** From ~368 seconds (fast suite) to **~332-345 seconds** (~5.5-5.8 minutes) with remaining optimizations
**Note:** Detailed analysis documents available:
- User LiveView Tests: See "Priority 1: User LiveView Tests Optimization" section above
- Policy Tests: See "Priority 2: Policy Tests Optimization" section above
---
## Risk Assessment Summary
### Overall Risk Level: ⚠️ **Low**
All optimizations maintain test coverage while improving performance:
| Optimization | Risk Level | Mitigation |
|-------------|------------|------------|
| Seeds tests reduction | ⚠️ Low | Coverage mapped to domain tests |
| Performance tests tagging | ✅ Very Low | Tests still executed, just separately |
| Critical test optimization | ✅ Very Low | Functionality unchanged, better isolation |
| Future optimizations | ⚠️ Low | Careful implementation with verification |
### Monitoring Plan
#### Success Criteria
- ✅ Seeds tests execute in <20 seconds consistently
- ✅ No increase in seeds-related deployment failures
- ✅ No regression in authorization or membership fee bugs
- ✅ Top 20 slowest tests: < 60 seconds (currently ~44s)
- ✅ Total execution time (without `:slow`): < 10 minutes (currently 6.1 min)
- ⏳ Slow tests execution time: < 2 minutes (currently ~1.3 min)
#### What to Watch For
1. **Production Seeds Failures:**
- Monitor deployment logs for seeds errors
- If failures increase, consider restoring detailed tests
2. **Authorization Bugs After Seeds Changes:**
- If role/permission bugs appear after seeds modifications
- May indicate need for more seeds-specific role validation
3. **Test Performance Regression:**
- Monitor test execution times in CI
- Alert if times increase significantly
4. **Developer Feedback:**
- If developers report missing test coverage
- Adjust based on real-world experience
---
## Benchmarking and Analysis
### How to Benchmark Tests
**ExUnit Built-in Benchmarking:**
The test suite is configured to show the slowest tests automatically:
```elixir
# test/test_helper.exs
ExUnit.start(
slowest: 10 # Shows 10 slowest tests at the end of test run
)
```
**Run Benchmark Analysis:**
```bash
# Show slowest tests
mix test --slowest 20
# Exclude slow tests for faster feedback
mix test --exclude slow --slowest 20
# Run only slow tests
mix test --only slow --slowest 10
# Benchmark specific test file
mix test test/mv_web/member_live/index_member_fields_display_test.exs --slowest 5
```
### Benchmarking Best Practices
1. **Run benchmarks regularly** (e.g., monthly) to catch performance regressions
2. **Compare isolated vs. full runs** to identify test isolation issues
3. **Monitor CI execution times** to track trends over time
4. **Document significant changes** in test performance
---
## Test Suite Structure
### Test Execution Modes
**Fast Tests (Default):**
- Excludes slow tests (`@tag :slow`)
- Used for standard development workflow
- Execution time: ~6 minutes
- Command: `mix test --exclude slow` or `just test-fast`
**Slow Tests:**
- Tests tagged with `@tag :slow` or `@describetag :slow` (25 tests)
- Low risk, >1 second execution time
- UI/Display tests, workflow details, edge cases, performance tests
- Execution time: ~1.3 minutes
- Command: `mix test --only slow` or `just test-slow`
- Excluded from standard CI runs
**Full Test Suite (via Promotion):**
- Triggered by promoting a build to `production` in Drone CI
- Runs all tests (`mix test`) for comprehensive coverage
- Execution time: ~7.4 minutes
- Required before merging to `main` (enforced via branch protection)
**All Tests:**
- Includes both fast and slow tests
- Used for comprehensive validation (pre-merge)
- Execution time: ~7.4 minutes
- Command: `mix test` or `just test`
### Test Organization
Tests are organized to mirror the `lib/` directory structure:
```
test/
├── accounts/ # Accounts domain tests
├── membership/ # Membership domain tests
├── membership_fees/ # Membership fees domain tests
├── mv/ # Core application tests
│ ├── accounts/ # User-related tests
│ ├── membership/ # Member-related tests
│ └── authorization/ # Authorization tests
├── mv_web/ # Web layer tests
│ ├── controllers/ # Controller tests
│ ├── live/ # LiveView tests
│ └── components/ # Component tests
└── support/ # Test helpers
├── conn_case.ex # Controller test setup
└── data_case.ex # Database test setup
```
---
## Best Practices for Test Performance
### When Writing New Tests
1. **Use `async: true`** when possible (for parallel execution)
2. **Filter queries** to only load necessary data
3. **Share fixtures** in `setup_all` when appropriate
4. **Tag performance tests** with `@tag :slow` if they use large datasets
5. **Keep test data minimal** - only create what's needed for the test
### When Optimizing Existing Tests
1. **Measure first** - Use `mix test --slowest` to identify bottlenecks
2. **Compare isolated vs. full runs** - Identify test isolation issues
3. **Optimize setup** - Move shared data to `setup_all` where possible
4. **Filter queries** - Only load data needed for the test
5. **Verify coverage** - Ensure optimizations don't reduce test coverage
### Test Tagging Guidelines
#### Tag as `@tag :slow` when:
1. **Performance Tests:**
- Explicitly testing performance characteristics
- Using large datasets (50+ records)
- Testing scalability or query optimization
- Validating N+1 query prevention
2. **Low-Risk Tests (>1s):**
- UI/Display/Formatting tests (not critical for every commit)
- Workflow detail tests (not core functionality)
- Edge cases with large datasets
- Show page tests (core functionality covered by Index/Form tests)
- Non-critical seeds tests (smoke tests, idempotency)
#### Do NOT tag as `@tag :slow` when:
- ❌ Test is slow due to inefficient setup (fix the setup instead)
- ❌ Test is slow due to bugs (fix the bug instead)
- ❌ Core CRUD operations (Member/User Create/Update/Destroy)
- ❌ Basic Authentication/Authorization
- ❌ Critical Bootstrap (Admin user, system roles)
- ❌ Email Synchronization
- ❌ Representative Policy tests (one per Permission Set + Action)
- ❌ It's an integration test (use `@tag :integration` instead)
---
## Changelog
### 2026-01-28: Initial Optimization Phase
**Completed:**
- ✅ Reduced seeds tests from 13 to 4 tests
- ✅ Tagged 9 performance tests with `@tag :slow`
- ✅ Optimized critical test with query filtering
- ✅ Created slow test suite infrastructure
- ✅ Updated CI/CD to exclude slow tests from standard runs
- ✅ Added promotion-based full test suite pipeline (`check-full`)
**Time Saved:** ~21-30 seconds per test run
### 2026-01-28: Full Test Suite via Promotion Implementation
**Completed:**
- ✅ Analyzed all tests for full test suite candidates
- ✅ Identified 36 tests with low risk and >1s execution time
- ✅ Tagged 25 tests with `@tag :slow` for full test suite (via promotion)
- ✅ Categorized tests by risk level and execution time
- ✅ Documented tagging criteria and guidelines
**Tests Tagged:**
- 2 Seeds tests (non-critical) - 18.1s
- 3 UserLive.ShowTest tests - 10.8s
- 5 UserLive.IndexTest tests - 25.0s
- 3 MemberLive.IndexCustomFieldsDisplayTest tests - 4.9s
- 3 MemberLive.IndexCustomFieldsEdgeCasesTest tests - 3.6s
- 7 RoleLive tests - 7.7s
- 1 MemberAvailableForLinkingTest - 1.5s
- 1 Performance test (already tagged) - 3.8s
**Time Saved:** ~77 seconds per test run
**Total Optimization Impact:**
- **Before:** ~445 seconds (7.4 minutes)
- **After (fast suite):** ~368 seconds (6.1 minutes)
- **Time saved:** ~77 seconds (17% reduction)
**Next Steps:**
- ⏳ Monitor full test suite execution via promotion in CI
- ⏳ Optimize remaining slow tests (Policy tests, etc.)
- ⏳ Further optimize Seeds tests (Priority 3)
---
## References
- **Testing Standards:** `CODE_GUIDELINES.md` - Section 4 (Testing Standards)
- **CI/CD Configuration:** `.drone.yml`
- **Test Helper:** `test/test_helper.exs`
- **Justfile Commands:** `Justfile` (test-fast, test-slow, test-all)
---
## Questions & Answers
**Q: What if seeds create wrong data and break the system?**
A: The smoke test will fail if seeds raise errors. Domain tests ensure business logic is correct regardless of seeds content.
**Q: What if we add a new critical bootstrap requirement?**
A: Add a new test to the "Critical bootstrap invariants" section in `test/seeds_test.exs`.
**Q: How do we know the removed tests aren't needed?**
A: Monitor for 2-3 months. If no seeds-related bugs appear that would have been caught by removed tests, they were redundant.
**Q: Should we restore the tests for important releases?**
A: Consider running the full test suite (including slow tests) before major releases. Daily development uses the optimized suite.
**Q: How do I add a new performance test?**
A: Tag it with `@tag :slow` for individual tests or `@describetag :slow` for describe blocks. Use `@describetag` instead of `@moduletag` to avoid tagging unrelated tests. Include measurable performance assertions (query counts, timing with tolerance, etc.). See "Performance Test Guidelines" section above.
**Q: Can I run slow tests locally?**
A: Yes, use `just test-slow` or `mix test --only slow`. They're excluded from standard runs for faster feedback.
**Q: What is the "full test suite"?**
A: The full test suite runs **all tests** (`mix test`), including slow and UI tests. Tests tagged with `@tag :slow` or `@describetag :slow` are excluded from standard CI runs (`check-fast`) for faster feedback, but are included when promoting a build to `production` (`check-full`) before merging to `main`.
**Q: Which tests should I tag as `:slow`?**
A: Tag tests with `@tag :slow` if they: (1) take >1 second, (2) have low risk (not critical for catching regressions), and (3) test UI/Display/Formatting or workflow details. See "Test Tagging Guidelines" section for details.
**Q: What if a slow test fails in the full test suite?**
A: If a test in the full test suite fails, investigate the failure. If it indicates a critical regression, consider moving it back to the fast suite. If it's a flaky test, fix the test itself. The merge will be blocked until all tests pass.

View file

@ -50,7 +50,8 @@ defmodule Mv.Membership.CustomField do
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
postgres do
table "custom_fields"
@ -79,6 +80,13 @@ defmodule Mv.Membership.CustomField do
end
end
policies do
policy action_type([:read, :create, :update, :destroy]) do
description "Check permissions from user's role"
authorize_if Mv.Authorization.Checks.HasPermission
end
end
attributes do
uuid_primary_key :id

View file

@ -471,11 +471,12 @@ defmodule Mv.Membership.Member do
end
end
# Validate required custom fields
validate fn changeset, _ ->
# Validate required custom fields (actor from validation context only; no fallback)
validate fn changeset, context ->
provided_values = provided_custom_field_values(changeset)
actor = context.actor
case Mv.Membership.list_required_custom_fields() do
case Mv.Membership.list_required_custom_fields(actor: actor) do
{:ok, required_custom_fields} ->
missing_fields = missing_required_fields(required_custom_fields, provided_values)
@ -485,6 +486,24 @@ defmodule Mv.Membership.Member do
build_custom_field_validation_error(missing_fields)
end
{:error, %Ash.Error.Forbidden{}} ->
Logger.warning(
"Required custom fields validation: actor not authorized to read CustomField"
)
{:error,
field: :custom_field_values,
message:
"You are not authorized to perform this action. Please sign in again or contact support."}
{:error, :missing_actor} ->
Logger.warning("Required custom fields validation: no actor in context")
{:error,
field: :custom_field_values,
message:
"You are not authorized to perform this action. Please sign in again or contact support."}
{:error, error} ->
Logger.error(
"Failed to load custom fields for validation: #{inspect(error)}. Required field validation cannot be performed."

View file

@ -14,7 +14,7 @@ defmodule Mv.Membership do
The domain exposes these main actions:
- Member CRUD: `create_member/1`, `list_members/0`, `update_member/2`, `destroy_member/1`
- Custom field value management: `create_custom_field_value/1`, `list_custom_field_values/0`, etc.
- Custom field management: `create_custom_field/1`, `list_custom_fields/0`, `list_required_custom_fields/0`, etc.
- Custom field management: `create_custom_field/1`, `list_custom_fields/0`, `list_required_custom_fields/1`, etc.
- Settings management: `get_settings/0`, `update_settings/2`, `update_member_field_visibility/2`, `update_single_member_field_visibility/3`
- Group management: `create_group/1`, `list_groups/0`, `update_group/2`, `destroy_group/1`
- Member-group associations: `create_member_group/1`, `list_member_groups/0`, `destroy_member_group/1`
@ -155,25 +155,37 @@ defmodule Mv.Membership do
Lists only required custom fields.
This is an optimized version that filters at the database level instead of
loading all custom fields and filtering in memory.
loading all custom fields and filtering in memory. Requires an actor for
authorization (CustomField read policy). Callers must pass `actor:`; no default.
## Options
- `:actor` - Required. The actor for authorization (e.g. current user).
All roles can read CustomField; actor must have a valid role.
## Returns
- `{:ok, required_custom_fields}` - List of required custom fields
- `{:error, error}` - Error reading custom fields
- `{:error, :missing_actor}` - When actor is nil (caller must pass actor)
- `{:error, error}` - Error reading custom fields (e.g. Forbidden)
## Examples
iex> {:ok, required_fields} = Mv.Membership.list_required_custom_fields()
iex> {:ok, required_fields} = Mv.Membership.list_required_custom_fields(actor: actor)
iex> Enum.all?(required_fields, & &1.required)
true
iex> Mv.Membership.list_required_custom_fields(actor: nil)
{:error, :missing_actor}
"""
def list_required_custom_fields do
def list_required_custom_fields(actor: actor) when not is_nil(actor) do
Mv.Membership.CustomField
|> Ash.Query.filter(expr(required == true))
|> Ash.read(domain: __MODULE__)
|> Ash.read(domain: __MODULE__, actor: actor)
end
def list_required_custom_fields(actor: nil), do: {:error, :missing_actor}
@doc """
Updates the member field visibility configuration.

View file

@ -91,7 +91,8 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
@impl true
def handle_event("save", %{"custom_field" => custom_field_params}, socket) do
actor = MvWeb.LiveHelpers.current_actor(socket)
# Actor must be passed from parent (IndexComponent); component socket has no current_user
actor = socket.assigns[:actor]
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, custom_field_params, actor) do
{:ok, custom_field} ->

View file

@ -12,6 +12,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
"""
use MvWeb, :live_component
require Logger
@impl true
def render(assigns) do
assigns = assign(assigns, :field_type_label, &MvWeb.Translations.FieldTypes.label/1)
@ -38,6 +40,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
<.live_component
module={MvWeb.CustomFieldLive.FormComponent}
id={@form_id}
actor={@actor}
custom_field={@editing_custom_field}
on_save={
fn custom_field, action -> send(self(), {:custom_field_saved, custom_field, action}) end
@ -176,6 +179,21 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
"""
end
defp stream_custom_fields(actor, parent_pid) do
case Ash.read(Mv.Membership.CustomField, actor: actor) do
{:ok, custom_fields} ->
custom_fields
{:error, error} ->
Logger.warning(
"CustomFieldLive.IndexComponent: failed to load custom fields: #{inspect(error)}"
)
send(parent_pid, {:custom_fields_load_error, error})
[]
end
end
@impl true
def update(assigns, socket) do
# Track previous show_form state to detect when form is closed
@ -207,7 +225,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|> assign_new(:show_delete_modal, fn -> false end)
|> assign_new(:custom_field_to_delete, fn -> nil end)
|> assign_new(:slug_confirmation, fn -> "" end)
|> stream(:custom_fields, Ash.read!(Mv.Membership.CustomField), reset: true)}
|> stream(:custom_fields, stream_custom_fields(assigns[:actor], self()), reset: true)}
end
@impl true
@ -226,7 +244,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
@impl true
def handle_event("edit_custom_field", %{"id" => id}, socket) do
custom_field = Ash.get!(Mv.Membership.CustomField, id)
actor = socket.assigns[:actor]
custom_field = Ash.get!(Mv.Membership.CustomField, id, actor: actor)
# Only send event if form was not already open
if not socket.assigns[:show_form] do
@ -242,7 +261,13 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
@impl true
def handle_event("prepare_delete", %{"id" => id}, socket) do
custom_field = Ash.get!(Mv.Membership.CustomField, id, load: [:assigned_members_count])
actor = socket.assigns[:actor]
custom_field =
Ash.get!(Mv.Membership.CustomField, id,
load: [:assigned_members_count],
actor: actor
)
{:noreply,
socket
@ -259,9 +284,10 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
@impl true
def handle_event("confirm_delete", _params, socket) do
custom_field = socket.assigns.custom_field_to_delete
actor = socket.assigns[:actor]
if socket.assigns.slug_confirmation == custom_field.slug do
case Ash.destroy(custom_field) do
case Ash.destroy(custom_field, actor: actor) do
:ok ->
send(self(), {:custom_field_deleted, custom_field})

View file

@ -130,6 +130,7 @@ defmodule MvWeb.GlobalSettingsLive do
:if={@active_editing_section != :member_fields}
module={MvWeb.CustomFieldLive.IndexComponent}
id="custom-fields-component"
actor={@current_user}
/>
</.form_section>
@ -503,6 +504,15 @@ defmodule MvWeb.GlobalSettingsLive do
{:noreply, put_flash(socket, :error, gettext("Slug does not match. Deletion cancelled."))}
end
def handle_info({:custom_fields_load_error, _error}, socket) do
{:noreply,
put_flash(
socket,
:error,
gettext("Could not load data fields. Please check your permissions.")
)}
end
@impl true
def handle_info({:editing_section_changed, section}, socket) do
{:noreply, assign(socket, :active_editing_section, section)}

View file

@ -113,16 +113,22 @@ defmodule MvWeb.GroupLive.Index do
defp load_groups(actor) do
require Ash.Query
# Load groups without aggregates first (faster)
query =
Mv.Membership.Group
|> Ash.Query.load(:member_count)
|> Ash.Query.sort(:name)
opts = ash_actor_opts(actor)
case Ash.read(query, opts) do
{:ok, groups} ->
groups
# Load all member counts in a single batch query (avoids N+1)
member_counts = load_member_counts_batch(groups)
# Attach counts to groups
Enum.map(groups, fn group ->
Map.put(group, :member_count, Map.get(member_counts, group.id, 0))
end)
{:error, _error} ->
require Logger
@ -130,4 +136,33 @@ defmodule MvWeb.GroupLive.Index do
[]
end
end
# Loads all member counts for groups using DB-side aggregation for better performance
# This avoids N+1 queries when loading member_count aggregate for each group
@spec load_member_counts_batch([Mv.Membership.Group.t()]) :: %{
Ecto.UUID.t() => non_neg_integer()
}
defp load_member_counts_batch(groups) do
group_ids = Enum.map(groups, & &1.id)
if Enum.empty?(group_ids) do
%{}
else
# Use Ecto directly for efficient GROUP BY COUNT query
# This is much more performant than loading aggregates for each group individually
# Note: We bypass Ash here for performance, but this is a simple read-only query
import Ecto.Query
query =
from mg in Mv.Membership.MemberGroup,
where: mg.group_id in ^group_ids,
group_by: mg.group_id,
select: {mg.group_id, count(mg.id)}
results = Mv.Repo.all(query)
results
|> Enum.into(%{}, fn {group_id, count} -> {group_id, count} end)
end
end
end

View file

@ -38,7 +38,16 @@ defmodule MvWeb.GroupLive.Show do
end
defp load_group_by_slug(socket, slug, actor) do
case Membership.get_group_by_slug(slug, actor: actor, load: [:members, :member_count]) do
# Load group with members and member_count
# Using explicit load ensures efficient preloading of members relationship
require Ash.Query
query =
Mv.Membership.Group
|> Ash.Query.filter(slug == ^slug)
|> Ash.Query.load([:members, :member_count])
case Ash.read_one(query, actor: actor, domain: Mv.Membership) do
{:ok, nil} ->
{:noreply,
socket

View file

@ -226,7 +226,7 @@ defmodule MvWeb.MemberLive.Form do
def mount(params, _session, socket) do
# current_user should be set by on_mount hooks (LiveUserAuth + LiveHelpers)
actor = current_actor(socket)
{:ok, custom_fields} = Mv.Membership.list_custom_fields()
{:ok, custom_fields} = Mv.Membership.list_custom_fields(actor: actor)
initial_custom_field_values =
Enum.map(custom_fields, fn cf ->

View file

@ -328,8 +328,12 @@ defmodule MvWeb.MemberLive.Index do
{new_field, new_order} = determine_new_sort(field, socket)
old_field = socket.assigns.sort_field
socket
|> update_sort_components(socket.assigns.sort_field, new_field, new_order)
|> assign(:sort_field, new_field)
|> assign(:sort_order, new_order)
|> update_sort_components(old_field, new_field, new_order)
|> push_sort_url(new_field, new_order)
end

View file

@ -90,11 +90,14 @@ defmodule MvWeb.UserLive.Index do
# Selects one user in the list of users
@impl true
def handle_event("select_user", %{"id" => id}, socket) do
# Normalize ID to string for consistent comparison
id_str = to_string(id)
selected =
if id in socket.assigns.selected_users do
List.delete(socket.assigns.selected_users, id)
if id_str in socket.assigns.selected_users do
List.delete(socket.assigns.selected_users, id_str)
else
[id | socket.assigns.selected_users]
[id_str | socket.assigns.selected_users]
end
{:noreply, assign(socket, :selected_users, selected)}
@ -129,7 +132,8 @@ defmodule MvWeb.UserLive.Index do
def handle_event("select_all", _params, socket) do
users = socket.assigns.users
all_ids = Enum.map(users, & &1.id)
# Normalize IDs to strings for consistent comparison
all_ids = Enum.map(users, &to_string(&1.id))
selected =
if Enum.sort(socket.assigns.selected_users) == Enum.sort(all_ids) do

View file

@ -17,7 +17,7 @@
type="checkbox"
name="select_all"
phx-click="select_all"
checked={Enum.sort(@selected_users) == Enum.map(@users, & &1.id) |> Enum.sort()}
checked={Enum.sort(@selected_users) == Enum.map(@users, &to_string(&1.id)) |> Enum.sort()}
aria-label={gettext("Select all users")}
role="checkbox"
/>
@ -26,10 +26,10 @@
>
<.input
type="checkbox"
name={user.id}
name={to_string(user.id)}
phx-click="select_user"
phx-value-id={user.id}
checked={user.id in @selected_users}
phx-value-id={to_string(user.id)}
checked={to_string(user.id) in @selected_users}
phx-capture-click
phx-stop-propagation
aria-label={gettext("Select user")}

View file

@ -2262,3 +2262,13 @@ msgstr "Dieser Benutzer kann nicht bearbeitet werden."
#, elixir-autogen, elixir-format
msgid "This user cannot be viewed."
msgstr "Dieser Benutzer kann nicht angezeigt werden."
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Not authorized."
msgstr "Nicht berechtigt."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Could not load data fields. Please check your permissions."
msgstr "Datenfelder konnten nicht geladen werden. Bitte überprüfen Sie Ihre Berechtigungen."

View file

@ -2268,3 +2268,8 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "Not authorized."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Could not load data fields. Please check your permissions."
msgstr ""

View file

@ -2263,3 +2263,13 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "This user cannot be viewed."
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Not authorized."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Could not load data fields. Please check your permissions."
msgstr ""

View file

@ -118,10 +118,12 @@ for attrs <- [
required: false
}
] do
# Bootstrap: no admin user yet; CustomField create requires admin, so skip authorization
Membership.create_custom_field!(
attrs,
upsert?: true,
upsert_identity: :unique_name
upsert_identity: :unique_name,
authorize?: false
)
end
@ -379,12 +381,16 @@ Enum.each(member_attrs_list, fn member_attrs ->
final_member =
if is_nil(member.membership_fee_type_id) and
Map.has_key?(member_attrs_without_status, :membership_fee_type_id) do
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: member_attrs_without_status.membership_fee_type_id
})
|> Ash.Changeset.put_context(:actor, admin_user_with_role)
|> Ash.update!(actor: admin_user_with_role)
{:ok, updated} =
Membership.update_member(
member,
%{
membership_fee_type_id: member_attrs_without_status.membership_fee_type_id
},
actor: admin_user_with_role
)
updated
else
member
end
@ -544,9 +550,12 @@ Enum.with_index(linked_members)
fee_type_index = rem(3 + index, length(all_fee_types))
fee_type = Enum.at(all_fee_types, fee_type_index)
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!(actor: admin_user_with_role)
{:ok, updated} =
Membership.update_member(member, %{membership_fee_type_id: fee_type.id},
actor: admin_user_with_role
)
updated
else
member
end
@ -594,7 +603,7 @@ end)
# Create sample custom field values for some members
all_members = Ash.read!(Membership.Member, actor: admin_user_with_role)
all_custom_fields = Ash.read!(Membership.CustomField)
all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_role)
# Helper function to find custom field by name
find_field = fn name -> Enum.find(all_custom_fields, &(&1.name == name)) end

View file

@ -239,13 +239,14 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
# Helper functions
defp create_member(actor) do
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User#{System.unique_integer([:positive])}",
email: "test#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: actor)
Mv.Membership.create_member(
%{
first_name: "Test",
last_name: "User#{System.unique_integer([:positive])}",
email: "test#{System.unique_integer([:positive])}@example.com"
},
actor: actor
)
end
defp create_custom_field(name, value_type, actor) do

View file

@ -17,13 +17,14 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
# Create a test member
{:ok, member} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User",
email: "test.validation@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{
first_name: "Test",
last_name: "User",
email: "test.validation@example.com"
},
actor: system_actor
)
# Create custom fields for different types
{:ok, string_field} =

View file

@ -79,6 +79,7 @@ defmodule Mv.Membership.GroupIntegrationTest do
end
end
@moduletag :slow
describe "Query Performance" do
test "preloading groups with members avoids N+1 queries", %{actor: actor} do
# Create test data

View file

@ -130,6 +130,7 @@ defmodule Mv.Membership.MemberAvailableForLinkingTest do
end)
end
@tag :slow
test "limits results to 10 members even when more exist" do
system_actor = Mv.Helpers.SystemActor.get_system_actor()

View file

@ -38,10 +38,8 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!(actor: actor)
{:ok, member} = Mv.Membership.create_member(attrs, actor: actor)
member
end
# Helper to create a cycle

View file

@ -14,31 +14,22 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
# Create test members
{:ok, member1} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
actor: system_actor
)
{:ok, member2} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Bob",
last_name: "Brown",
email: "bob@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{first_name: "Bob", last_name: "Brown", email: "bob@example.com"},
actor: system_actor
)
{:ok, member3} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Charlie",
last_name: "Clark",
email: "charlie@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{first_name: "Charlie", last_name: "Clark", email: "charlie@example.com"},
actor: system_actor
)
# Create custom fields for different types
{:ok, string_field} =
@ -112,9 +103,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
# Force search_vector update by reloading member
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update(actor: system_actor)
Mv.Membership.update_member(member1, %{}, actor: system_actor)
# Search for the custom field value
results =
@ -143,9 +132,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update(actor: system_actor)
Mv.Membership.update_member(member1, %{}, actor: system_actor)
# Search for the custom field value
results =
@ -174,9 +161,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update(actor: system_actor)
Mv.Membership.update_member(member1, %{}, actor: system_actor)
# Search for partial custom field value (should work via FTS or custom field filter)
results =
@ -225,9 +210,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update(actor: system_actor)
Mv.Membership.update_member(member1, %{}, actor: system_actor)
# Search for the custom field value (date is stored as text in search_vector)
results =
@ -256,9 +239,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update(actor: system_actor)
Mv.Membership.update_member(member1, %{}, actor: system_actor)
# Search for the custom field value (boolean is stored as "true" or "false" text)
results =
@ -287,9 +268,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update(actor: system_actor)
Mv.Membership.update_member(member1, %{}, actor: system_actor)
# Update custom field value
{:ok, _updated_cfv} =
@ -334,9 +313,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update(actor: system_actor)
Mv.Membership.update_member(member1, %{}, actor: system_actor)
# Verify it's searchable
results =
@ -401,9 +378,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
# Update member (should trigger search_vector update including custom fields)
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{notes: "Updated notes"})
|> Ash.update(actor: system_actor)
Mv.Membership.update_member(member1, %{notes: "Updated notes"}, actor: system_actor)
# Search should find the custom field value
results =
@ -452,9 +427,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update(actor: system_actor)
Mv.Membership.update_member(member1, %{}, actor: system_actor)
# All values should be searchable
results1 =
@ -496,9 +469,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update(actor: system_actor)
Mv.Membership.update_member(member1, %{}, actor: system_actor)
# Search for full value (should work via search_vector)
results_full =
@ -541,9 +512,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
# Force search_vector update
{:ok, _updated_member} =
member1
|> Ash.Changeset.for_update(:update_member, %{})
|> Ash.update(actor: system_actor)
Mv.Membership.update_member(member1, %{}, actor: system_actor)
# Search for full phone number (should work via search_vector)
results_full =

View file

@ -41,10 +41,8 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!(actor: actor)
{:ok, member} = Mv.Membership.create_member(attrs, actor: actor)
member
end
# Helper to create a cycle
@ -76,10 +74,14 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
# Manually assign fee type (this will trigger cycle generation)
member =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!(actor: actor)
|> then(fn m ->
{:ok, updated} =
Mv.Membership.update_member(m, %{membership_fee_type_id: yearly_type1.id},
actor: actor
)
updated
end)
# Cycle generation runs synchronously in the same transaction
# No need to wait for async completion
@ -140,11 +142,9 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
# Change membership fee type (same interval, different amount)
assert {:ok, _updated_member} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update(actor: actor)
Mv.Membership.update_member(member, %{membership_fee_type_id: yearly_type2.id},
actor: actor
)
# Cycle regeneration runs synchronously in the same transaction
# No need to wait for async completion
@ -194,10 +194,14 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
# Manually assign fee type (this will trigger cycle generation)
member =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!(actor: actor)
|> then(fn m ->
{:ok, updated} =
Mv.Membership.update_member(m, %{membership_fee_type_id: yearly_type1.id},
actor: actor
)
updated
end)
# Cycle generation runs synchronously in the same transaction
# No need to wait for async completion
@ -215,11 +219,9 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
# Change membership fee type
assert {:ok, _updated_member} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update(actor: actor)
Mv.Membership.update_member(member, %{membership_fee_type_id: yearly_type2.id},
actor: actor
)
# Cycle regeneration runs synchronously in the same transaction
# No need to wait for async completion
@ -242,10 +244,14 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
# Manually assign fee type (this will trigger cycle generation)
member =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!(actor: actor)
|> then(fn m ->
{:ok, updated} =
Mv.Membership.update_member(m, %{membership_fee_type_id: yearly_type1.id},
actor: actor
)
updated
end)
# Cycle generation runs synchronously in the same transaction
# No need to wait for async completion
@ -263,11 +269,9 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
# Change membership fee type
assert {:ok, _updated_member} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update(actor: actor)
Mv.Membership.update_member(member, %{membership_fee_type_id: yearly_type2.id},
actor: actor
)
# Cycle regeneration runs synchronously in the same transaction
# No need to wait for async completion
@ -290,10 +294,14 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
# Manually assign fee type (this will trigger cycle generation)
member =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!(actor: actor)
|> then(fn m ->
{:ok, updated} =
Mv.Membership.update_member(m, %{membership_fee_type_id: yearly_type1.id},
actor: actor
)
updated
end)
# Cycle generation runs synchronously in the same transaction
# No need to wait for async completion
@ -357,11 +365,9 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
# Change membership fee type
assert {:ok, _updated_member} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update(actor: actor)
Mv.Membership.update_member(member, %{membership_fee_type_id: yearly_type2.id},
actor: actor
)
# Cycle regeneration runs synchronously in the same transaction
# No need to wait for async completion
@ -406,10 +412,14 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
# Manually assign fee type (this will trigger cycle generation)
member =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!(actor: actor)
|> then(fn m ->
{:ok, updated} =
Mv.Membership.update_member(m, %{membership_fee_type_id: yearly_type1.id},
actor: actor
)
updated
end)
# Cycle generation runs synchronously in the same transaction
# No need to wait for async completion
@ -463,11 +473,9 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
# Change membership fee type
assert {:ok, updated_member} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update(actor: actor)
Mv.Membership.update_member(member, %{membership_fee_type_id: yearly_type2.id},
actor: actor
)
# Cycle regeneration runs synchronously in the same transaction
# No need to wait for async completion

View file

@ -146,16 +146,17 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
|> Ash.create!(actor: actor)
# Create member with join_date and fee type but no explicit start date
member =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2024-03-15],
membership_fee_type_id: fee_type.id
})
|> Ash.create!(actor: actor)
{:ok, member} =
Mv.Membership.create_member(
%{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2024-03-15],
membership_fee_type_id: fee_type.id
},
actor: actor
)
# Should have auto-calculated start date (2024-01-01 for yearly with include_joining_cycle=true)
assert member.membership_fee_start_date == ~D[2024-01-01]
@ -177,17 +178,18 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
# Create member with explicit start date
manual_start_date = ~D[2024-07-01]
member =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2024-03-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: manual_start_date
})
|> Ash.create!(actor: actor)
{:ok, member} =
Mv.Membership.create_member(
%{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2024-03-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: manual_start_date
},
actor: actor
)
# Should keep the manually set date
assert member.membership_fee_start_date == manual_start_date
@ -207,16 +209,17 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
|> Ash.create!(actor: actor)
# Create member
member =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2024-03-15],
membership_fee_type_id: fee_type.id
})
|> Ash.create!(actor: actor)
{:ok, member} =
Mv.Membership.create_member(
%{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2024-03-15],
membership_fee_type_id: fee_type.id
},
actor: actor
)
# Should have next cycle start date (2025-01-01 for yearly with include_joining_cycle=false)
assert member.membership_fee_start_date == ~D[2025-01-01]
@ -236,16 +239,17 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
|> Ash.create!(actor: actor)
# Create member without join_date
member =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
# No join_date
})
|> Ash.create!(actor: actor)
{:ok, member} =
Mv.Membership.create_member(
%{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
# No join_date
},
actor: actor
)
# Should not have auto-calculated start date
assert is_nil(member.membership_fee_start_date)
@ -255,16 +259,17 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
setup_settings(true, actor)
# Create member without fee type
member =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2024-03-15]
# No membership_fee_type_id
})
|> Ash.create!(actor: actor)
{:ok, member} =
Mv.Membership.create_member(
%{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2024-03-15]
# No membership_fee_type_id
},
actor: actor
)
# Should not have auto-calculated start date
assert is_nil(member.membership_fee_start_date)

View file

@ -4,7 +4,6 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
"""
use Mv.DataCase, async: true
alias Mv.Membership.Member
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.Changes.ValidateSameInterval
@ -37,10 +36,8 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!(actor: actor)
{:ok, member} = Mv.Membership.create_member(attrs, actor: actor)
member
end
describe "validate_interval_match/1" do
@ -52,9 +49,9 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: yearly_type2.id},
actor: actor
)
|> ValidateSameInterval.change(%{}, %{})
assert changeset.valid?
@ -68,9 +65,9 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: monthly_type.id
})
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: monthly_type.id},
actor: actor
)
|> ValidateSameInterval.change(%{}, %{})
refute changeset.valid?
@ -90,9 +87,9 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type.id
})
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: yearly_type.id},
actor: actor
)
|> ValidateSameInterval.change(%{}, %{})
assert changeset.valid?
@ -104,9 +101,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: nil
})
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: nil}, actor: actor)
|> ValidateSameInterval.change(%{}, %{})
refute changeset.valid?
@ -124,9 +119,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{
first_name: "New Name"
})
|> Ash.Changeset.for_update(:update_member, %{first_name: "New Name"}, actor: actor)
|> ValidateSameInterval.change(%{}, %{})
assert changeset.valid?
@ -140,9 +133,9 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: quarterly_type.id
})
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: quarterly_type.id},
actor: actor
)
|> ValidateSameInterval.change(%{}, %{})
error = Enum.find(changeset.errors, &(&1.field == :membership_fee_type_id))
@ -179,9 +172,9 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: type2.id
})
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: type2.id},
actor: actor
)
|> ValidateSameInterval.change(%{}, %{})
refute changeset.valid?,
@ -199,11 +192,9 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
# Try to update member with different interval type
assert {:error, %Ash.Error.Invalid{} = error} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: monthly_type.id
})
|> Ash.update(actor: actor)
Mv.Membership.update_member(member, %{membership_fee_type_id: monthly_type.id},
actor: actor
)
# Check that error is about interval mismatch
error_message = extract_error_message(error)
@ -220,11 +211,9 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
# Update member with same-interval type
assert {:ok, updated_member} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update(actor: actor)
Mv.Membership.update_member(member, %{membership_fee_type_id: yearly_type2.id},
actor: actor
)
assert updated_member.membership_fee_type_id == yearly_type2.id
end

View file

@ -52,16 +52,17 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :yearly}, actor)
member =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-03-15],
membership_fee_type_id: fee_type.id
})
|> Ash.create!(actor: actor)
{:ok, member} =
Mv.Membership.create_member(
%{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-03-15],
membership_fee_type_id: fee_type.id
},
actor: actor
)
cycles = get_member_cycles(member.id, actor)
@ -80,16 +81,16 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
test "does not create cycles when member has no fee type", %{actor: actor} do
setup_settings(true, actor)
member =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-03-15]
# No membership_fee_type_id
})
|> Ash.create!(actor: actor)
{:ok, member} =
Mv.Membership.create_member(
%{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-03-15]
},
actor: actor
)
cycles = get_member_cycles(member.id, actor)
@ -100,16 +101,16 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :yearly}, actor)
member =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
# No join_date
})
|> Ash.create!(actor: actor)
{:ok, member} =
Mv.Membership.create_member(
%{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
},
actor: actor
)
cycles = get_member_cycles(member.id, actor)
@ -123,23 +124,23 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
fee_type = create_fee_type(%{interval: :yearly}, actor)
# Create member without fee type
member =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-03-15]
})
|> Ash.create!(actor: actor)
{:ok, member} =
Mv.Membership.create_member(
%{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-03-15]
},
actor: actor
)
# Verify no cycles yet
assert get_member_cycles(member.id, actor) == []
# Update to assign fee type
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!(actor: actor)
{:ok, member} =
Mv.Membership.update_member(member, %{membership_fee_type_id: fee_type.id}, actor: actor)
cycles = get_member_cycles(member.id, actor)
@ -157,15 +158,19 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
tasks =
Enum.map(1..5, fn i ->
Task.async(fn ->
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test#{i}",
last_name: "User#{i}",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-03-15],
membership_fee_type_id: fee_type.id
})
|> Ash.create!(actor: actor)
{:ok, member} =
Mv.Membership.create_member(
%{
first_name: "Test#{i}",
last_name: "User#{i}",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-03-15],
membership_fee_type_id: fee_type.id
},
actor: actor
)
member
end)
end)
@ -184,16 +189,17 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
setup_settings(true, actor)
fee_type = create_fee_type(%{interval: :yearly}, actor)
member =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-03-15],
membership_fee_type_id: fee_type.id
})
|> Ash.create!(actor: actor)
{:ok, member} =
Mv.Membership.create_member(
%{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-03-15],
membership_fee_type_id: fee_type.id
},
actor: actor
)
initial_cycles = get_member_cycles(member.id, actor)
initial_count = length(initial_cycles)

View file

@ -37,10 +37,8 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!(actor: actor)
{:ok, member} = Mv.Membership.create_member(attrs, actor: actor)
member
end
# Helper to create a cycle

View file

@ -0,0 +1,188 @@
defmodule Mv.Membership.CustomFieldPoliciesTest do
@moduledoc """
Tests for CustomField resource authorization policies.
Verifies that all authenticated users with a valid role can read custom fields,
and only admin can create/update/destroy custom fields.
"""
use Mv.DataCase, async: false
alias Mv.Membership.CustomField
alias Mv.Accounts
alias Mv.Authorization
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
defp create_role_with_permission_set(permission_set_name, actor) do
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
case Authorization.create_role(
%{
name: role_name,
description: "Test role for #{permission_set_name}",
permission_set_name: permission_set_name
},
actor: actor
) do
{:ok, role} -> role
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
end
end
defp create_user_with_permission_set(permission_set_name, actor) do
role = create_role_with_permission_set(permission_set_name, actor)
{:ok, user} =
Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "user#{System.unique_integer([:positive])}@example.com",
password: "testpassword123"
})
|> Ash.create(actor: actor)
{:ok, user} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|> Ash.update(actor: actor)
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
user_with_role
end
defp create_custom_field(actor) do
{:ok, field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test_field_#{System.unique_integer([:positive])}",
value_type: :string
})
|> Ash.create(actor: actor, domain: Mv.Membership)
field
end
describe "read access (all roles)" do
test "user with own_data can read all custom fields", %{actor: actor} do
custom_field = create_custom_field(actor)
user = create_user_with_permission_set("own_data", actor)
{:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership)
ids = Enum.map(fields, & &1.id)
assert custom_field.id in ids
{:ok, fetched} = Ash.get(CustomField, custom_field.id, actor: user, domain: Mv.Membership)
assert fetched.id == custom_field.id
end
test "user with read_only can read all custom fields", %{actor: actor} do
custom_field = create_custom_field(actor)
user = create_user_with_permission_set("read_only", actor)
{:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership)
ids = Enum.map(fields, & &1.id)
assert custom_field.id in ids
{:ok, fetched} = Ash.get(CustomField, custom_field.id, actor: user, domain: Mv.Membership)
assert fetched.id == custom_field.id
end
test "user with normal_user can read all custom fields", %{actor: actor} do
custom_field = create_custom_field(actor)
user = create_user_with_permission_set("normal_user", actor)
{:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership)
ids = Enum.map(fields, & &1.id)
assert custom_field.id in ids
{:ok, fetched} = Ash.get(CustomField, custom_field.id, actor: user, domain: Mv.Membership)
assert fetched.id == custom_field.id
end
test "user with admin can read all custom fields", %{actor: actor} do
custom_field = create_custom_field(actor)
user = create_user_with_permission_set("admin", actor)
{:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership)
ids = Enum.map(fields, & &1.id)
assert custom_field.id in ids
{:ok, fetched} = Ash.get(CustomField, custom_field.id, actor: user, domain: Mv.Membership)
assert fetched.id == custom_field.id
end
end
describe "write access - non-admin cannot create/update/destroy" do
setup %{actor: actor} do
user = create_user_with_permission_set("normal_user", actor)
custom_field = create_custom_field(actor)
%{user: user, custom_field: custom_field}
end
test "non-admin cannot create custom field (forbidden)", %{user: user} do
assert {:error, %Ash.Error.Forbidden{}} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "forbidden_field_#{System.unique_integer([:positive])}",
value_type: :string
})
|> Ash.create(actor: user, domain: Mv.Membership)
end
test "non-admin cannot update custom field (forbidden)", %{
user: user,
custom_field: custom_field
} do
assert {:error, %Ash.Error.Forbidden{}} =
custom_field
|> Ash.Changeset.for_update(:update, %{description: "Updated"})
|> Ash.update(actor: user, domain: Mv.Membership)
end
test "non-admin cannot destroy custom field (forbidden)", %{
user: user,
custom_field: custom_field
} do
assert {:error, %Ash.Error.Forbidden{}} =
Ash.destroy(custom_field, actor: user, domain: Mv.Membership)
end
end
describe "write access - admin can create/update/destroy" do
setup %{actor: actor} do
user = create_user_with_permission_set("admin", actor)
custom_field = create_custom_field(actor)
%{user: user, custom_field: custom_field}
end
test "admin can create custom field", %{user: user} do
name = "admin_field_#{System.unique_integer([:positive])}"
assert {:ok, %CustomField{} = field} =
CustomField
|> Ash.Changeset.for_create(:create, %{name: name, value_type: :string})
|> Ash.create(actor: user, domain: Mv.Membership)
assert field.name == name
end
test "admin can update custom field", %{user: user, custom_field: custom_field} do
assert {:ok, updated} =
custom_field
|> Ash.Changeset.for_update(:update, %{description: "Admin updated"})
|> Ash.update(actor: user, domain: Mv.Membership)
assert updated.description == "Admin updated"
end
test "admin can destroy custom field", %{user: user, custom_field: custom_field} do
assert :ok = Ash.destroy(custom_field, actor: user, domain: Mv.Membership)
assert {:error, _} =
Ash.get(CustomField, custom_field.id, domain: Mv.Membership, actor: user)
end
end
end

View file

@ -9,7 +9,7 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
# async: false because we need database commits to be visible across queries
use Mv.DataCase, async: false
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
alias Mv.Membership.{CustomField, CustomFieldValue}
alias Mv.Accounts
alias Mv.Authorization
@ -62,13 +62,14 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
defp create_linked_member_for_user(user, actor) do
{:ok, member} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Linked",
last_name: "Member",
email: "linked#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: actor, return_notifications?: false)
Mv.Membership.create_member(
%{
first_name: "Linked",
last_name: "Member",
email: "linked#{System.unique_integer([:positive])}@example.com"
},
actor: actor
)
user
|> Ash.Changeset.for_update(:update, %{})
@ -80,13 +81,14 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
defp create_unlinked_member(actor) do
{:ok, member} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Unlinked",
last_name: "Member",
email: "unlinked#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: actor)
Mv.Membership.create_member(
%{
first_name: "Unlinked",
last_name: "Member",
email: "unlinked#{System.unique_integer([:positive])}@example.com"
},
actor: actor
)
member
end

View file

@ -78,13 +78,14 @@ defmodule Mv.Membership.MemberPoliciesTest do
# NOTE: We need to ensure the member is actually persisted to the database
# before we try to link it. Ash may delay writes, so we explicitly return the struct.
{:ok, member} =
Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Linked",
last_name: "Member",
email: "linked#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: admin, return_notifications?: false)
Membership.create_member(
%{
first_name: "Linked",
last_name: "Member",
email: "linked#{System.unique_integer([:positive])}@example.com"
},
actor: admin
)
# Link member to user (User.member_id = member.id)
# We use force_change_attribute because the member already exists and we just
@ -108,13 +109,14 @@ defmodule Mv.Membership.MemberPoliciesTest do
admin = create_admin_user(actor)
{:ok, member} =
Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Unlinked",
last_name: "Member",
email: "unlinked#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: admin)
Membership.create_member(
%{
first_name: "Unlinked",
last_name: "Member",
email: "unlinked#{System.unique_integer([:positive])}@example.com"
},
actor: admin
)
member
end
@ -145,9 +147,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
# Update is allowed via HasPermission check with :linked scope (not via special case)
# The special case policy only applies to :read actions
{:ok, updated_member} =
linked_member
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update(actor: user)
Membership.update_member(linked_member, %{first_name: "Updated"}, actor: user)
assert updated_member.first_name == "Updated"
end
@ -168,11 +168,8 @@ defmodule Mv.Membership.MemberPoliciesTest do
user: user,
unlinked_member: unlinked_member
} do
assert_raise Ash.Error.Forbidden, fn ->
unlinked_member
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update!(actor: user)
end
assert {:error, %Ash.Error.Forbidden{}} =
Membership.update_member(unlinked_member, %{first_name: "Updated"}, actor: user)
end
test "list members returns only linked member", %{
@ -187,15 +184,15 @@ defmodule Mv.Membership.MemberPoliciesTest do
end
test "cannot create member (returns forbidden)", %{user: user} do
assert_raise Ash.Error.Forbidden, fn ->
Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "New",
last_name: "Member",
email: "new#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create!(actor: user)
end
assert {:error, %Ash.Error.Forbidden{}} =
Membership.create_member(
%{
first_name: "New",
last_name: "Member",
email: "new#{System.unique_integer([:positive])}@example.com"
},
actor: user
)
end
test "cannot destroy member (returns forbidden)", %{
@ -245,26 +242,23 @@ defmodule Mv.Membership.MemberPoliciesTest do
end
test "cannot create member (returns forbidden)", %{user: user} do
assert_raise Ash.Error.Forbidden, fn ->
Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "New",
last_name: "Member",
email: "new#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create!(actor: user)
end
assert {:error, %Ash.Error.Forbidden{}} =
Membership.create_member(
%{
first_name: "New",
last_name: "Member",
email: "new#{System.unique_integer([:positive])}@example.com"
},
actor: user
)
end
test "cannot update any member (returns forbidden)", %{
user: user,
linked_member: linked_member
} do
assert_raise Ash.Error.Forbidden, fn ->
linked_member
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update!(actor: user)
end
assert {:error, %Ash.Error.Forbidden{}} =
Membership.update_member(linked_member, %{first_name: "Updated"}, actor: user)
end
test "cannot destroy any member (returns forbidden)", %{
@ -305,22 +299,21 @@ defmodule Mv.Membership.MemberPoliciesTest do
test "can create member", %{user: user} do
{:ok, member} =
Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "New",
last_name: "Member",
email: "new#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: user)
Membership.create_member(
%{
first_name: "New",
last_name: "Member",
email: "new#{System.unique_integer([:positive])}@example.com"
},
actor: user
)
assert member.first_name == "New"
end
test "can update any member", %{user: user, unlinked_member: unlinked_member} do
{:ok, updated_member} =
unlinked_member
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update(actor: user)
Membership.update_member(unlinked_member, %{first_name: "Updated"}, actor: user)
assert updated_member.first_name == "Updated"
end
@ -363,22 +356,21 @@ defmodule Mv.Membership.MemberPoliciesTest do
test "can create member", %{user: user} do
{:ok, member} =
Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "New",
last_name: "Member",
email: "new#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: user)
Membership.create_member(
%{
first_name: "New",
last_name: "Member",
email: "new#{System.unique_integer([:positive])}@example.com"
},
actor: user
)
assert member.first_name == "New"
end
test "can update any member", %{user: user, unlinked_member: unlinked_member} do
{:ok, updated_member} =
unlinked_member
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update(actor: user)
Membership.update_member(unlinked_member, %{first_name: "Updated"}, actor: user)
assert updated_member.first_name == "Updated"
end
@ -456,9 +448,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
# Should succeed via HasPermission check (not special case)
{:ok, updated_member} =
linked_member
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update(actor: user)
Membership.update_member(linked_member, %{first_name: "Updated"}, actor: user)
assert updated_member.first_name == "Updated"
end

View file

@ -49,10 +49,8 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!(actor: actor)
{:ok, member} = Mv.Membership.create_member(attrs, actor: actor)
member
end
# Helper to create a member and explicitly generate cycles with a fixed "today" date.
@ -74,9 +72,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
# Assign fee type if provided (this will trigger auto-generation with real today)
member =
if fee_type_id do
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type_id})
|> Ash.update!(actor: actor)
{:ok, updated} =
Mv.Membership.update_member(member, %{membership_fee_type_id: fee_type_id},
actor: actor
)
updated
else
member
end
@ -130,10 +131,8 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
)
# Assign fee type
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!(actor: actor)
{:ok, member} =
Mv.Membership.update_member(member, %{membership_fee_type_id: fee_type.id}, actor: actor)
# Explicitly generate cycles with fixed "today" date
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
@ -163,10 +162,8 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
)
# Assign fee type
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!(actor: actor)
{:ok, member} =
Mv.Membership.update_member(member, %{membership_fee_type_id: fee_type.id}, actor: actor)
# Explicitly generate cycles with fixed "today" date
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
@ -349,10 +346,8 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|> Ash.create!(actor: actor)
# Now assign fee type
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!(actor: actor)
{:ok, member} =
Mv.Membership.update_member(member, %{membership_fee_type_id: fee_type.id}, actor: actor)
# Explicitly generate cycles with fixed "today" date
today = ~D[2024-06-15]
@ -616,13 +611,15 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
)
# Now assign fee type (simulating a retroactive assignment)
member =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2021-01-01]
})
|> Ash.update!(actor: actor)
{:ok, member} =
Mv.Membership.update_member(
member,
%{
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2021-01-01]
},
actor: actor
)
# Run batch generation with a "today" date after the member left
today = ~D[2024-06-15]

View file

@ -40,10 +40,8 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!(actor: actor)
{:ok, member} = Mv.Membership.create_member(attrs, actor: actor)
member
end
# Helper to set up settings with specific include_joining_cycle value
@ -81,8 +79,12 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
# Assign fee type
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!(actor: actor)
|> then(fn m ->
{:ok, updated} =
Mv.Membership.update_member(m, %{membership_fee_type_id: fee_type.id}, actor: actor)
updated
end)
# Explicitly generate cycles with fixed "today" date to avoid date dependency
today = ~D[2024-06-15]
@ -128,8 +130,12 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
# Now assign fee type to member
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!(actor: actor)
|> then(fn m ->
{:ok, updated} =
Mv.Membership.update_member(m, %{membership_fee_type_id: fee_type.id}, actor: actor)
updated
end)
# Generate cycles with specific "today" date
today = ~D[2024-06-15]
@ -237,8 +243,12 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
# start from the last existing cycle (2023) and go forward
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!(actor: actor)
|> then(fn m ->
{:ok, updated} =
Mv.Membership.update_member(m, %{membership_fee_type_id: fee_type.id}, actor: actor)
updated
end)
# Verify gap was NOT filled and new cycles were generated from last existing
all_cycles = get_member_cycles(member.id, actor)

View file

@ -16,8 +16,10 @@ defmodule MvWeb.Components.MemberFilterComponentTest do
alias Mv.Membership.CustomField
# Helper to create a boolean custom field
# Helper to create a boolean custom field (uses system_actor - only admin can create)
defp create_boolean_custom_field(attrs \\ %{}) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
default_attrs = %{
name: "test_boolean_#{System.unique_integer([:positive])}",
value_type: :boolean
@ -27,11 +29,13 @@ defmodule MvWeb.Components.MemberFilterComponentTest do
CustomField
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
|> Ash.create!(actor: system_actor)
end
# Helper to create a non-boolean custom field
# Helper to create a non-boolean custom field (uses system_actor - only admin can create)
defp create_string_custom_field(attrs \\ %{}) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
default_attrs = %{
name: "test_string_#{System.unique_integer([:positive])}",
value_type: :string
@ -41,7 +45,7 @@ defmodule MvWeb.Components.MemberFilterComponentTest do
CustomField
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
|> Ash.create!(actor: system_actor)
end
describe "rendering" do

View file

@ -80,21 +80,20 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|> Ash.create!(actor: actor)
# Create member without fee type first to avoid auto-generation
member =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2022-01-01]
})
|> Ash.create!(actor: actor)
{:ok, member} =
Mv.Membership.create_member(
%{
first_name: "Test",
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2022-01-01]
},
actor: actor
)
# Assign fee type after member creation (this may generate cycles, but we'll create our own)
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!(actor: actor)
{:ok, member} =
Mv.Membership.update_member(member, %{membership_fee_type_id: fee_type.id}, actor: actor)
# Delete any auto-generated cycles first
cycles =
@ -151,20 +150,19 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|> Ash.create!(actor: actor)
# Create member without fee type first
member =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create!(actor: actor)
{:ok, member} =
Mv.Membership.create_member(
%{
first_name: "Test",
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com"
},
actor: actor
)
# Assign fee type
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!(actor: actor)
{:ok, member} =
Mv.Membership.update_member(member, %{membership_fee_type_id: fee_type.id}, actor: actor)
# Delete any auto-generated cycles
cycles =
@ -197,21 +195,20 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|> Ash.create!(actor: actor)
# Create member without fee type first
member =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-01-01]
})
|> Ash.create!(actor: actor)
{:ok, member} =
Mv.Membership.create_member(
%{
first_name: "Test",
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-01-01]
},
actor: actor
)
# Assign fee type
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!(actor: actor)
{:ok, member} =
Mv.Membership.update_member(member, %{membership_fee_type_id: fee_type.id}, actor: actor)
# Delete any auto-generated cycles
cycles =

View file

@ -15,13 +15,15 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
admin_role = Mv.Fixtures.role_fixture("admin")
# Create admin user for testing
# Create admin user for testing (must have admin role to read/manage CustomField)
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
@ -30,8 +32,18 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
})
|> Ash.create(actor: system_actor)
conn = log_in_user(build_conn(), user)
%{conn: conn, user: user}
{:ok, user} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update(actor: system_actor)
user_with_role = Ash.load!(user, :role, domain: Mv.Accounts, actor: system_actor)
conn = log_in_user(build_conn(), user_with_role)
# Use English locale so "Delete" link text matches in assertions
session = conn.private[:plug_session] || %{}
conn = Plug.Test.init_test_session(conn, Map.put(session, "locale", "en"))
%{conn: conn, user: user_with_role}
end
describe "delete button and modal" do
@ -107,9 +119,8 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|> element("#delete-custom-field-modal form")
|> render_change(%{"slug" => custom_field.slug})
# Confirm button should be enabled now (no disabled attribute)
html = render(view)
refute html =~ ~r/disabled(?:=""|(?!\w))/
# Confirm button should be enabled now (no disabled attribute on the confirm button)
refute has_element?(view, "#delete-custom-field-modal button.btn-error[disabled]")
end
test "delete button is disabled when slug doesn't match", %{conn: conn} do
@ -126,9 +137,8 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|> element("#delete-custom-field-modal form")
|> render_change(%{"slug" => "wrong-slug"})
# Button should be disabled
html = render(view)
assert html =~ ~r/disabled(?:=""|(?!\w))/
# Confirm button should be disabled
assert has_element?(view, "#delete-custom-field-modal button.btn-error[disabled]")
end
end
@ -186,10 +196,8 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|> element("#delete-custom-field-modal form")
|> render_change(%{"slug" => "wrong-slug"})
# Button should be disabled and we cannot click it
# The test verifies that the button is properly disabled in the UI
html = render(view)
assert html =~ ~r/disabled(?:=""|(?!\w))/
# Confirm button should be disabled and we cannot click it
assert has_element?(view, "#delete-custom-field-modal button.btn-error[disabled]")
# Custom field should still exist since deletion couldn't proceed
system_actor = Mv.Helpers.SystemActor.get_system_actor()
@ -224,17 +232,58 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
end
end
# Helper functions
describe "create custom field" do
test "submitting new data field form creates custom field and shows success", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/settings")
# Open "New Data Field" form
view
|> element("#custom-fields-component button", "New Data Field")
|> render_click()
# Form is visible; submit with valid data
form_params = %{
"custom_field" => %{
"name" => "Created via Form",
"value_type" => "string",
"description" => "",
"required" => "false",
"show_in_overview" => "true"
}
}
view
|> form("#custom-field-form-new-form", form_params)
|> render_submit()
# Success flash (FormComponent needs actor from parent; without it KeyError would occur)
assert render(view) =~ "successfully"
# Custom field was created in DB
system_actor = Mv.Helpers.SystemActor.get_system_actor()
search_name = "Created via Form"
[custom_field] =
Mv.Membership.CustomField
|> Ash.Query.filter(name == ^search_name)
|> Ash.read!(actor: system_actor)
assert custom_field.value_type == :string
end
end
# Helper functions (use code interface so actor is in validation context)
defp create_member do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User#{System.unique_integer([:positive])}",
email: "test#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{
first_name: "Test",
last_name: "User#{System.unique_integer([:positive])}",
email: "test#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
end
defp create_custom_field(name, value_type) do

View file

@ -115,15 +115,35 @@ defmodule MvWeb.GroupLive.IndexTest do
end
describe "performance" do
@describetag :slow
test "page loads efficiently with many groups", %{conn: conn} do
# Create multiple groups
Enum.each(1..10, fn _ -> Fixtures.group_fixture() end)
# Count queries using Telemetry
query_count_agent = Agent.start_link(fn -> 0 end) |> elem(1)
handler = fn _event, _measurements, _metadata, _config ->
Agent.update(query_count_agent, &(&1 + 1))
end
handler_id = "test-query-counter-#{System.unique_integer([:positive])}"
:telemetry.attach(handler_id, [:ash, :query, :start], handler, nil)
# Should load without N+1 queries
{:ok, _view, html} = live(conn, "/groups")
final_count = Agent.get(query_count_agent, & &1)
:telemetry.detach(handler_id)
# Verify all groups are displayed
assert html =~ gettext("Groups")
# Verify query count is reasonable (should avoid N+1 queries)
# Expected: 1 query for groups list + 1 batch query for member counts + LiveView setup queries
# Allow overhead for authorization, LiveView setup, and other initialization queries
assert final_count <= 12,
"Expected max 12 queries (groups list + batch member counts + LiveView setup + auth), got #{final_count}. This suggests N+1 query problem."
end
test "member count is loaded efficiently via calculation", %{conn: conn} do
@ -142,10 +162,29 @@ defmodule MvWeb.GroupLive.IndexTest do
actor: system_actor
)
# Count queries using Telemetry
query_count_agent = Agent.start_link(fn -> 0 end) |> elem(1)
handler = fn _event, _measurements, _metadata, _config ->
Agent.update(query_count_agent, &(&1 + 1))
end
handler_id = "test-query-counter-#{System.unique_integer([:positive])}"
:telemetry.attach(handler_id, [:ash, :query, :start], handler, nil)
{:ok, _view, html} = live(conn, "/groups")
final_count = Agent.get(query_count_agent, & &1)
:telemetry.detach(handler_id)
# Member count should be displayed (should be 2)
assert html =~ "2" or html =~ gettext("Members") or html =~ "Mitglieder"
# Verify query count is reasonable (member count should be calculated efficiently)
# Expected: 1 query for groups + 1 batch query for member counts + LiveView setup queries
# Allow overhead for authorization, LiveView setup, and other initialization queries
assert final_count <= 12,
"Expected max 12 queries (groups + batch member counts + LiveView setup + auth), got #{final_count}. This suggests inefficient member count calculation."
end
end
end

View file

@ -220,6 +220,7 @@ defmodule MvWeb.GroupLive.ShowTest do
end
describe "performance" do
@describetag :slow
test "member list is loaded efficiently (no N+1 queries)", %{conn: conn} do
group = Fixtures.group_fixture()
@ -235,12 +236,32 @@ defmodule MvWeb.GroupLive.ShowTest do
)
end)
# Count queries using Telemetry
query_count_agent = Agent.start_link(fn -> 0 end) |> elem(1)
handler = fn _event, _measurements, _metadata, _config ->
Agent.update(query_count_agent, &(&1 + 1))
end
handler_id = "test-query-counter-#{System.unique_integer([:positive])}"
:telemetry.attach(handler_id, [:ash, :query, :start], handler, nil)
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
final_count = Agent.get(query_count_agent, & &1)
:telemetry.detach(handler_id)
# All members should be displayed
Enum.each(members, fn member ->
assert html =~ member.first_name or html =~ member.last_name
end)
# Verify query count is reasonable (should avoid N+1 queries)
# Expected: 1 query for group lookup + 1 query for members (with preload) + member_count aggregate
# Allow overhead for authorization, LiveView setup, and other initialization queries
# Note: member_count aggregate and authorization checks may add additional queries
assert final_count <= 20,
"Expected max 20 queries (group + members preload + member_count aggregate + LiveView setup + auth), got #{final_count}. This suggests N+1 query problem."
end
test "slug lookup is efficient (uses unique_slug index)", %{conn: conn} do

View file

@ -7,7 +7,6 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
import Phoenix.LiveViewTest
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership.Member
require Ash.Query
@ -55,10 +54,8 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!(actor: system_actor)
{:ok, member} = Mv.Membership.create_member(attrs, actor: system_actor)
member
end
describe "create form" do

View file

@ -7,7 +7,6 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
import Phoenix.LiveViewTest
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership.Member
require Ash.Query
@ -31,7 +30,7 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
end
# Helper to create a member
# Uses admin actor from global setup to ensure authorization
# Uses admin actor from global setup to ensure authorization; falls back to system_actor when nil
defp create_member(attrs, actor) do
default_attrs = %{
first_name: "Test",
@ -40,12 +39,9 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
}
attrs = Map.merge(default_attrs, attrs)
opts = if actor, do: [actor: actor], else: []
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!(opts)
effective_actor = actor || Mv.Helpers.SystemActor.get_system_actor()
{:ok, member} = Mv.Membership.create_member(attrs, actor: effective_actor)
member
end
describe "list display" do

View file

@ -93,6 +93,7 @@ defmodule MvWeb.RoleLive.ShowTest do
%{conn: conn, actor: system_actor}
end
@tag :slow
test "mounts successfully with valid role ID", %{conn: conn} do
role = create_role()
@ -101,6 +102,7 @@ defmodule MvWeb.RoleLive.ShowTest do
assert html =~ role.name
end
@tag :slow
test "displays role name", %{conn: conn} do
role = create_role(%{name: "Test Role Name"})
@ -127,6 +129,7 @@ defmodule MvWeb.RoleLive.ShowTest do
assert html =~ gettext("No description")
end
@tag :slow
test "displays permission set name", %{conn: conn} do
role = create_role(%{permission_set_name: "read_only"})
@ -152,6 +155,7 @@ defmodule MvWeb.RoleLive.ShowTest do
assert html =~ gettext("Yes")
end
@tag :slow
test "displays non-system role badge when is_system_role is false", %{conn: conn} do
role = create_role()
@ -178,6 +182,7 @@ defmodule MvWeb.RoleLive.ShowTest do
%{conn: conn, actor: system_actor}
end
@tag :slow
test "back button navigates to role list", %{conn: conn} do
role = create_role()

View file

@ -98,6 +98,7 @@ defmodule MvWeb.RoleLiveTest do
%{conn: conn, actor: system_actor, user: user}
end
@tag :slow
test "mounts successfully", %{conn: conn} do
{:ok, _view, _html} = live(conn, "/admin/roles")
end
@ -388,6 +389,7 @@ defmodule MvWeb.RoleLiveTest do
%{conn: conn, actor: system_actor, user: user}
end
@tag :slow
test "deletes non-system role", %{conn: conn} do
role = create_role()

View file

@ -23,17 +23,12 @@ defmodule MvWeb.UserLive.ShowTest do
end
describe "mount and display" do
test "mounts successfully with valid user ID", %{conn: conn, user: user} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/users/#{user.id}")
assert html =~ to_string(user.email)
end
test "displays user email", %{conn: conn, user: user} do
@tag :ui
test "mounts successfully and displays user information", %{conn: conn, user: user} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/users/#{user.id}")
# Basic display
assert html =~ to_string(user.email)
assert html =~ gettext("Email")
end
@ -63,18 +58,16 @@ defmodule MvWeb.UserLive.ShowTest do
assert html =~ gettext("Not enabled")
end
@tag :slow
test "displays linked member when present", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create member
{:ok, member} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Alice",
last_name: "Smith",
email: "alice@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{first_name: "Alice", last_name: "Smith", email: "alice@example.com"},
actor: system_actor
)
# Create user and link to member
user = create_test_user(%{email: "user@example.com"})
@ -103,10 +96,12 @@ defmodule MvWeb.UserLive.ShowTest do
end
describe "navigation" do
test "back button navigates to user list", %{conn: conn, user: user} do
@tag :ui
test "navigation buttons work correctly", %{conn: conn, user: user} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, ~p"/users/#{user.id}")
# Back button navigates to user list
assert {:error, {:live_redirect, %{to: to}}} =
view
|> element(
@ -115,10 +110,8 @@ defmodule MvWeb.UserLive.ShowTest do
|> render_click()
assert to == "/users"
end
test "edit button navigates to edit form", %{conn: conn, user: user} do
conn = conn_with_oidc_user(conn)
# Edit button navigates to edit form
{:ok, view, _html} = live(conn, ~p"/users/#{user.id}")
assert {:error, {:live_redirect, %{to: to}}} =
@ -132,6 +125,17 @@ defmodule MvWeb.UserLive.ShowTest do
end
end
describe "page title" do
@tag :ui
test "sets correct page title", %{conn: conn, user: user} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/users/#{user.id}")
# Check that page title is set (might be in title tag or header)
assert html =~ gettext("Show User") || html =~ to_string(user.email)
end
end
describe "error handling" do
test "raises exception for invalid user ID", %{conn: conn} do
invalid_id = Ecto.UUID.generate()
@ -145,17 +149,8 @@ defmodule MvWeb.UserLive.ShowTest do
end
end
describe "page title" do
test "sets correct page title", %{conn: conn, user: user} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/users/#{user.id}")
# Check that page title is set (might be in title tag or header)
assert html =~ gettext("Show User") || html =~ to_string(user.email)
end
end
describe "system actor user" do
@tag :slow
test "redirects to user list when viewing system actor user", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)

View file

@ -6,8 +6,6 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
import Phoenix.LiveViewTest
alias Mv.Membership.Member
require Ash.Query
describe "error handling - flash messages" do
@ -16,13 +14,14 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
# Create a member with the same email to trigger uniqueness error
{:ok, _existing_member} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Existing",
last_name: "Member",
email: "duplicate@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{
first_name: "Existing",
last_name: "Member",
email: "duplicate@example.com"
},
actor: system_actor
)
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members/new")
@ -79,23 +78,21 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
# Create a member to edit
{:ok, member} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Original",
last_name: "Member",
email: "original@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{
first_name: "Original",
last_name: "Member",
email: "original@example.com"
},
actor: system_actor
)
# Create another member with different email
{:ok, _other_member} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Other",
last_name: "Member",
email: "other@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{first_name: "Other", last_name: "Member", email: "other@example.com"},
actor: system_actor
)
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")

View file

@ -6,7 +6,6 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
import Phoenix.LiveViewTest
alias Mv.Membership.Member
alias Mv.MembershipFees.MembershipFeeType
require Ash.Query
@ -37,10 +36,8 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!(actor: admin_user)
{:ok, member} = Mv.Membership.create_member(attrs, actor: admin_user)
member
end
describe "membership fee type dropdown" do
@ -129,7 +126,7 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
# Verify member was created with fee type - use admin_user to test permissions
member =
Member
Mv.Membership.Member
|> Ash.Query.filter(email == ^form_data["member[email]"])
|> Ash.read_one!(actor: admin_user)
@ -177,14 +174,15 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
# Create member with fee type 1 and custom field value
member =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type1.id
})
|> Ash.create!(actor: admin_user)
create_member(
%{
first_name: "Test",
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type1.id
},
admin_user
)
# Add custom field value
Mv.Membership.CustomFieldValue
@ -222,14 +220,15 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
# Create member with date custom field value
member =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
})
|> Ash.create!(actor: admin_user)
create_member(
%{
first_name: "Test",
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
},
admin_user
)
test_date = ~D[2024-01-15]
@ -269,14 +268,15 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
# Create member with custom field value
member =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
})
|> Ash.create!(actor: admin_user)
create_member(
%{
first_name: "Test",
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
},
admin_user
)
# Add custom field value
_cfv =

View file

@ -39,10 +39,8 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!(actor: system_actor)
{:ok, member} = Mv.Membership.create_member(attrs, actor: system_actor)
member
end
# Helper to create a cycle
@ -106,8 +104,14 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
# Assign fee type
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!(actor: system_actor)
|> then(fn m ->
{:ok, updated} =
Mv.Membership.update_member(m, %{membership_fee_type_id: fee_type.id},
actor: system_actor
)
updated
end)
# Delete any auto-generated cycles
cycles =
@ -151,8 +155,14 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
# Assign fee type
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!(actor: system_actor)
|> then(fn m ->
{:ok, updated} =
Mv.Membership.update_member(m, %{membership_fee_type_id: fee_type.id},
actor: system_actor
)
updated
end)
# Delete any auto-generated cycles
cycles =
@ -192,8 +202,14 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
# Assign fee type
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!(actor: system_actor)
|> then(fn m ->
{:ok, updated} =
Mv.Membership.update_member(m, %{membership_fee_type_id: fee_type.id},
actor: system_actor
)
updated
end)
# Delete any auto-generated cycles
cycles =

View file

@ -11,20 +11,17 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
alias Mv.Membership.{CustomField, CustomFieldValue}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test member
{:ok, member} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
actor: system_actor
)
# Create custom field with show_in_overview: true
{:ok, field} =

View file

@ -14,29 +14,23 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
alias Mv.Membership.{CustomField, CustomFieldValue}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test members
{:ok, member1} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
actor: system_actor
)
{:ok, member2} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Bob",
last_name: "Brown",
email: "bob@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{first_name: "Bob", last_name: "Brown", email: "bob@example.com"},
actor: system_actor
)
# Create custom fields
{:ok, field_show_string} =
@ -161,6 +155,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
}
end
@tag :slow
test "displays custom field with show_in_overview: true", %{
conn: conn,
member1: _member1,
@ -229,6 +224,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
assert html =~ "true" or html =~ "Yes" or html =~ "Ja"
end
@tag :slow
test "formats date custom field values correctly", %{conn: conn, member1: _member1} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
@ -237,6 +233,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
assert html =~ "15.05.1990"
end
@tag :slow
test "formats email custom field values correctly", %{conn: conn, member1: _member1} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")

View file

@ -10,29 +10,24 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{CustomField, Member}
alias Mv.Membership.CustomField
@tag :slow
test "displays custom field column even when no members have values", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test members without custom field values
{:ok, _member1} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
actor: system_actor
)
{:ok, _member2} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Bob",
last_name: "Brown",
email: "bob@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{first_name: "Bob", last_name: "Brown", email: "bob@example.com"},
actor: system_actor
)
# Create custom field with show_in_overview: true but no values
{:ok, field} =
@ -51,18 +46,16 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
assert html =~ field.name
end
@tag :slow
test "displays very long custom field values correctly", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test member
{:ok, member} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
actor: system_actor
)
# Create custom field
{:ok, field} =
@ -94,18 +87,16 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
assert html =~ "A" or html =~ long_value
end
@tag :slow
test "handles multiple custom fields with show_in_overview correctly", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test member
{:ok, member} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
actor: system_actor
)
# Create multiple custom fields with show_in_overview: true
{:ok, field1} =

View file

@ -13,38 +13,29 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
alias Mv.Membership.{CustomField, CustomFieldValue}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test members
{:ok, member1} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
actor: system_actor
)
{:ok, member2} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Bob",
last_name: "Brown",
email: "bob@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{first_name: "Bob", last_name: "Brown", email: "bob@example.com"},
actor: system_actor
)
{:ok, member3} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Charlie",
last_name: "Clark",
email: "charlie@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{first_name: "Charlie", last_name: "Clark", email: "charlie@example.com"},
actor: system_actor
)
# Create custom field with show_in_overview: true
{:ok, field_string} =
@ -242,40 +233,28 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
# Create additional members with NULL and empty string values
{:ok, member_with_value} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "WithValue",
last_name: "Test",
email: "withvalue@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{first_name: "WithValue", last_name: "Test", email: "withvalue@example.com"},
actor: system_actor
)
{:ok, member_with_empty} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "WithEmpty",
last_name: "Test",
email: "withempty@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{first_name: "WithEmpty", last_name: "Test", email: "withempty@example.com"},
actor: system_actor
)
{:ok, member_with_null} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "WithNull",
last_name: "Test",
email: "withnull@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{first_name: "WithNull", last_name: "Test", email: "withnull@example.com"},
actor: system_actor
)
{:ok, member_with_another_value} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "AnotherValue",
last_name: "Test",
email: "another@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{first_name: "AnotherValue", last_name: "Test", email: "another@example.com"},
actor: system_actor
)
# Create custom field
{:ok, field} =
@ -355,40 +334,28 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
# Create additional members with NULL and empty string values
{:ok, member_with_value} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "WithValue",
last_name: "Test",
email: "withvalue@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{first_name: "WithValue", last_name: "Test", email: "withvalue@example.com"},
actor: system_actor
)
{:ok, member_with_empty} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "WithEmpty",
last_name: "Test",
email: "withempty@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{first_name: "WithEmpty", last_name: "Test", email: "withempty@example.com"},
actor: system_actor
)
{:ok, member_with_null} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "WithNull",
last_name: "Test",
email: "withnull@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{first_name: "WithNull", last_name: "Test", email: "withnull@example.com"},
actor: system_actor
)
{:ok, member_with_another_value} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "AnotherValue",
last_name: "Test",
email: "another@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{first_name: "AnotherValue", last_name: "Test", email: "another@example.com"},
actor: system_actor
)
# Create custom field
{:ok, field} =

View file

@ -16,33 +16,35 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
alias Mv.Membership.{CustomField, CustomFieldValue}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test members
{:ok, member1} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com",
street: "Main St",
city: "Berlin"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com",
street: "Main St",
city: "Berlin"
},
actor: system_actor
)
{:ok, member2} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Bob",
last_name: "Brown",
email: "bob@example.com",
street: "Second St",
city: "Hamburg"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{
first_name: "Bob",
last_name: "Brown",
email: "bob@example.com",
street: "Second St",
city: "Hamburg"
},
actor: system_actor
)
# Create custom field
{:ok, custom_field} =

View file

@ -3,33 +3,29 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.Member
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, member1} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com",
street: "Main Street",
house_number: "123",
postal_code: "12345",
city: "Berlin",
join_date: ~D[2020-01-15]
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com",
street: "Main Street",
house_number: "123",
postal_code: "12345",
city: "Berlin",
join_date: ~D[2020-01-15]
},
actor: system_actor
)
{:ok, member2} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Bob",
last_name: "Brown",
email: "bob@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{first_name: "Bob", last_name: "Brown", email: "bob@example.com"},
actor: system_actor
)
%{
member1: member1,
@ -56,7 +52,9 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
})
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Use search query to filter to only the expected member (Alice)
# This significantly improves test performance by avoiding loading all members from other tests
{:ok, _view, html} = live(conn, "/members?query=Alice")
assert html =~ "Email"
assert html =~ m.email

View file

@ -6,7 +6,6 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
import Phoenix.LiveViewTest
alias Mv.Membership.Member
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
@ -40,10 +39,8 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!(actor: system_actor)
{:ok, member} = Mv.Membership.create_member(attrs, actor: system_actor)
member
end
# Helper to create a cycle
@ -238,6 +235,7 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
end
end
@moduletag :slow
describe "performance" do
test "loads cycles efficiently without N+1 queries", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})

View file

@ -531,10 +531,8 @@ defmodule MvWeb.MemberLive.IndexTest do
}
attrs = Map.merge(default_attrs, attrs)
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!(actor: actor)
{:ok, member} = Mv.Membership.create_member(attrs, actor: actor)
member
end
test "filter shows only members with paid status in last cycle", %{conn: conn} do
@ -750,8 +748,10 @@ defmodule MvWeb.MemberLive.IndexTest do
describe "boolean custom field filters" do
alias Mv.Membership.CustomField
# Helper to create a boolean custom field
# Helper to create a boolean custom field (uses system actor for authorization)
defp create_boolean_custom_field(attrs \\ %{}) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
default_attrs = %{
name: "test_boolean_#{System.unique_integer([:positive])}",
value_type: :boolean
@ -761,11 +761,13 @@ defmodule MvWeb.MemberLive.IndexTest do
CustomField
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
|> Ash.create!(actor: system_actor)
end
# Helper to create a non-boolean custom field
# Helper to create a non-boolean custom field (uses system actor for authorization)
defp create_string_custom_field(attrs \\ %{}) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
default_attrs = %{
name: "test_string_#{System.unique_integer([:positive])}",
value_type: :string
@ -775,7 +777,7 @@ defmodule MvWeb.MemberLive.IndexTest do
CustomField
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
|> Ash.create!(actor: system_actor)
end
test "mount initializes boolean_custom_field_filters as empty map", %{conn: conn} do
@ -1016,6 +1018,7 @@ defmodule MvWeb.MemberLive.IndexTest do
test "handle_params removes filter when custom field is deleted", %{conn: conn} do
conn = conn_with_oidc_user(conn)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
# Set up filter via URL
@ -1026,8 +1029,8 @@ defmodule MvWeb.MemberLive.IndexTest do
filters_before = state_before.socket.assigns.boolean_custom_field_filters
assert filters_before[boolean_field.id] == true
# Delete the custom field
Ash.destroy!(boolean_field)
# Delete the custom field (requires actor with destroy permission)
Ash.destroy!(boolean_field, actor: system_actor)
# Navigate again - filter should be removed since custom field no longer exists
{:ok, view2, _html} =
@ -1122,18 +1125,15 @@ defmodule MvWeb.MemberLive.IndexTest do
# Helper to create a member with a boolean custom field value
defp create_member_with_boolean_value(member_attrs, custom_field, value, actor) do
{:ok, member} =
Mv.Membership.Member
|> Ash.Changeset.for_create(
:create_member,
%{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
}
|> Map.merge(member_attrs)
)
|> Ash.create(actor: actor)
attrs =
%{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
}
|> Map.merge(member_attrs)
{:ok, member} = Mv.Membership.create_member(attrs, actor: actor)
{:ok, _cfv} =
Mv.Membership.CustomFieldValue
@ -1177,13 +1177,14 @@ defmodule MvWeb.MemberLive.IndexTest do
boolean_field = create_boolean_custom_field()
{:ok, member} =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
# Create CustomFieldValue with map format (Ash expects _union_type and _union_value)
{:ok, _cfv} =
@ -1210,13 +1211,14 @@ defmodule MvWeb.MemberLive.IndexTest do
boolean_field = create_boolean_custom_field()
{:ok, member} =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
# Member has no custom field value for this field
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
@ -1233,13 +1235,14 @@ defmodule MvWeb.MemberLive.IndexTest do
boolean_field = create_boolean_custom_field()
{:ok, member} =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
# Create CustomFieldValue with nil value (edge case)
{:ok, _cfv} =
@ -1266,13 +1269,14 @@ defmodule MvWeb.MemberLive.IndexTest do
boolean_field = create_boolean_custom_field()
{:ok, member} =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
# Create string custom field value (not boolean)
{:ok, _cfv} =
@ -1315,20 +1319,21 @@ defmodule MvWeb.MemberLive.IndexTest do
)
{:ok, member_without_value} =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "NoValue",
last_name: "Member",
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{
first_name: "NoValue",
last_name: "Member",
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
member_without_value =
member_without_value |> Ash.load!(:custom_field_values, actor: system_actor)
members = [member_with_true, member_with_false, member_without_value]
filters = %{to_string(boolean_field.id) => true}
all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor)
result =
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
@ -1365,20 +1370,21 @@ defmodule MvWeb.MemberLive.IndexTest do
)
{:ok, member_without_value} =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "NoValue",
last_name: "Member",
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{
first_name: "NoValue",
last_name: "Member",
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
member_without_value =
member_without_value |> Ash.load!(:custom_field_values, actor: system_actor)
members = [member_with_true, member_with_false, member_without_value]
filters = %{to_string(boolean_field.id) => false}
all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor)
result =
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
@ -1417,7 +1423,7 @@ defmodule MvWeb.MemberLive.IndexTest do
members = [member1, member2]
filters = %{}
all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor)
result =
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
@ -1442,13 +1448,14 @@ defmodule MvWeb.MemberLive.IndexTest do
# Member with both fields = true
{:ok, member_both_true} =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "BothTrue",
last_name: "Member",
email: "bothtrue.member.#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{
first_name: "BothTrue",
last_name: "Member",
email: "bothtrue.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
{:ok, _cfv1} =
Mv.Membership.CustomFieldValue
@ -1472,13 +1479,14 @@ defmodule MvWeb.MemberLive.IndexTest do
# Member with field1 = true, field2 = false
{:ok, member_mixed} =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Mixed",
last_name: "Member",
email: "mixed.member.#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{
first_name: "Mixed",
last_name: "Member",
email: "mixed.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
{:ok, _cfv3} =
Mv.Membership.CustomFieldValue
@ -1507,7 +1515,7 @@ defmodule MvWeb.MemberLive.IndexTest do
to_string(boolean_field2.id) => true
}
all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor)
result =
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
@ -1538,7 +1546,7 @@ defmodule MvWeb.MemberLive.IndexTest do
members = [member]
filters = %{fake_id => true}
all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor)
result =
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
@ -1575,13 +1583,14 @@ defmodule MvWeb.MemberLive.IndexTest do
)
{:ok, _member_without_value} =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "NoValue",
last_name: "Member",
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{
first_name: "NoValue",
last_name: "Member",
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
# Test true filter
{:ok, _view, html_true} =
@ -1610,14 +1619,15 @@ defmodule MvWeb.MemberLive.IndexTest do
# Member with true boolean value and paid status
{:ok, member_paid_true} =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "PaidTrue",
last_name: "Member",
email: "paidtrue.member.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{
first_name: "PaidTrue",
last_name: "Member",
email: "paidtrue.member.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
},
actor: system_actor
)
{:ok, _cfv} =
Mv.Membership.CustomFieldValue
@ -1637,14 +1647,15 @@ defmodule MvWeb.MemberLive.IndexTest do
# Member with true boolean value but unpaid status
{:ok, member_unpaid_true} =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "UnpaidTrue",
last_name: "Member",
email: "unpaidtrue.member.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{
first_name: "UnpaidTrue",
last_name: "Member",
email: "unpaidtrue.member.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
},
actor: system_actor
)
{:ok, _cfv2} =
Mv.Membership.CustomFieldValue
@ -1725,13 +1736,14 @@ defmodule MvWeb.MemberLive.IndexTest do
)
{:ok, _member_without_value} =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "NoValue",
last_name: "Member",
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{
first_name: "NoValue",
last_name: "Member",
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
# Test that filter works even though field is not visible in overview
{:ok, _view, html_true} =
@ -1775,6 +1787,7 @@ defmodule MvWeb.MemberLive.IndexTest do
assert Enum.any?(boolean_fields_after, &(&1.name == "Newly Added Field"))
end
@tag :slow
test "boolean filter performance with 150 members", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)

View file

@ -6,7 +6,6 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
import Phoenix.LiveViewTest
alias Mv.Membership.Member
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
@ -40,10 +39,8 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!(actor: system_actor)
{:ok, member} = Mv.Membership.create_member(attrs, actor: system_actor)
member
end
describe "end-to-end workflows" do
@ -152,7 +149,7 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
member =
Member
Mv.Membership.Member
|> Ash.Query.filter(email == ^form_data["member[email]"])
|> Ash.read_one!(actor: system_actor)

View file

@ -6,7 +6,6 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
import Phoenix.LiveViewTest
alias Mv.Membership.Member
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
@ -40,10 +39,8 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!(actor: system_actor)
{:ok, member} = Mv.Membership.create_member(attrs, actor: system_actor)
member
end
# Helper to create a cycle

View file

@ -18,20 +18,17 @@ defmodule MvWeb.MemberLive.ShowTest do
require Ash.Query
use Gettext, backend: MvWeb.Gettext
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
alias Mv.Membership.{CustomField, CustomFieldValue}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test member
{:ok, member} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com"
})
|> Ash.create(actor: system_actor)
Mv.Membership.create_member(
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
actor: system_actor
)
%{member: member, actor: system_actor}
end

View file

@ -10,29 +10,26 @@ defmodule MvWeb.UserLive.FormTest do
end
describe "new user form - display" do
test "shows correct form elements", %{conn: conn} do
@tag :ui
test "shows correct form elements and password field toggling", %{conn: conn} do
{:ok, view, html} = setup_live_view(conn, "/users/new")
# Basic form elements
assert html =~ "New User"
assert html =~ "Email"
assert html =~ "Set Password"
assert has_element?(view, "form#user-form[phx-submit='save']")
assert has_element?(view, "input[name='user[email]']")
assert has_element?(view, "input[type='checkbox'][name='set_password']")
end
test "hides password fields initially", %{conn: conn} do
{:ok, view, _html} = setup_live_view(conn, "/users/new")
# Password fields should be hidden initially
refute has_element?(view, "input[name='user[password]']")
refute has_element?(view, "input[name='user[password_confirmation]']")
end
test "shows password fields when checkbox toggled", %{conn: conn} do
{:ok, view, _html} = setup_live_view(conn, "/users/new")
# Toggle password fields
view |> element("input[name='set_password']") |> render_click()
# Password fields should now be visible
assert has_element?(view, "input[name='user[password]']")
assert has_element?(view, "input[name='user[password_confirmation]']")
assert render(view) =~ "Password requirements"
@ -115,7 +112,7 @@ defmodule MvWeb.UserLive.FormTest do
)
assert user.hashed_password != nil
assert String.starts_with?(user.hashed_password, "$2b$")
refute is_nil(user.hashed_password)
end
end
@ -153,22 +150,21 @@ defmodule MvWeb.UserLive.FormTest do
end
describe "edit user form - display" do
test "shows correct form elements for existing user", %{conn: conn} do
@tag :ui
test "shows correct form elements and admin password fields", %{conn: conn} do
user = create_test_user(%{email: "editme@example.com"})
{:ok, view, html} = setup_live_view(conn, "/users/#{user.id}/edit")
# Basic form elements
assert html =~ "Edit User"
assert html =~ "Change Password"
assert has_element?(view, "input[name='user[email]'][value='editme@example.com']")
assert html =~ "Check &#39;Change Password&#39; above to set a new password for this user"
end
test "shows admin password fields when enabled", %{conn: conn} do
user = create_test_user(%{email: "editme@example.com"})
{:ok, view, _html} = setup_live_view(conn, "/users/#{user.id}/edit")
# Toggle admin password fields
view |> element("input[name='set_password']") |> render_click()
# Admin password fields should be visible (no confirmation field for admin)
assert has_element?(view, "input[name='user[password]']")
refute has_element?(view, "input[name='user[password_confirmation]']")
assert render(view) =~ "Admin Note"
@ -214,7 +210,8 @@ defmodule MvWeb.UserLive.FormTest do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
updated_user = Ash.reload!(user, domain: Mv.Accounts, actor: system_actor)
assert updated_user.hashed_password != original_password
assert String.starts_with?(updated_user.hashed_password, "$2b$")
assert not is_nil(updated_user.hashed_password)
assert updated_user.hashed_password != ""
end
end
@ -258,39 +255,6 @@ defmodule MvWeb.UserLive.FormTest do
end
end
describe "internationalization" do
test "shows German labels", %{conn: conn} do
conn = conn_with_oidc_user(conn, %{email: "admin_de@example.com"})
conn = Plug.Test.init_test_session(conn, locale: "de")
{:ok, _view, html} = live(conn, "/users/new")
assert html =~ "Neue*r Benutzer*in"
assert html =~ "E-Mail"
assert html =~ "Passwort setzen"
end
test "shows English labels", %{conn: conn} do
conn = conn_with_oidc_user(conn, %{email: "admin_en@example.com"})
Gettext.put_locale(MvWeb.Gettext, "en")
{:ok, _view, html} = live(conn, "/users/new")
assert html =~ "New User"
assert html =~ "Email"
assert html =~ "Set Password"
end
test "shows different labels for edit vs new", %{conn: conn} do
user = create_test_user(%{email: "test@example.com"})
conn = conn_with_oidc_user(conn, %{email: "admin@example.com"})
{:ok, _view, new_html} = live(conn, "/users/new")
{:ok, _view, edit_html} = live(conn, "/users/#{user.id}/edit")
assert new_html =~ "Set Password"
assert edit_html =~ "Change Password"
end
end
describe "member linking - display" do
test "shows linked member with unlink button when user has member", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
@ -421,6 +385,39 @@ defmodule MvWeb.UserLive.FormTest do
end
end
describe "internationalization" do
@tag :ui
test "shows translated labels in different locales", %{conn: conn} do
# Test German labels
conn = conn_with_oidc_user(conn, %{email: "admin_de@example.com"})
conn = Plug.Test.init_test_session(conn, locale: "de")
{:ok, _view, html_de} = live(conn, "/users/new")
assert html_de =~ "Neue*r Benutzer*in"
assert html_de =~ "E-Mail"
assert html_de =~ "Passwort setzen"
# Test English labels
conn = conn_with_oidc_user(conn, %{email: "admin_en@example.com"})
conn = Plug.Test.init_test_session(conn, locale: "en")
{:ok, _view, html_en} = live(conn, "/users/new")
assert html_en =~ "New User"
assert html_en =~ "Email"
assert html_en =~ "Set Password"
# Test different labels for edit vs new
user = create_test_user(%{email: "test@example.com"})
conn = conn_with_oidc_user(conn, %{email: "admin@example.com"})
{:ok, _view, new_html} = live(conn, "/users/new")
{:ok, _view, edit_html} = live(conn, "/users/#{user.id}/edit")
assert new_html =~ "Set Password"
assert edit_html =~ "Change Password"
end
end
describe "system actor user" do
test "redirects to user list when editing system actor user", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()

View file

@ -3,46 +3,38 @@ defmodule MvWeb.UserLive.IndexTest do
import Phoenix.LiveViewTest
describe "basic functionality" do
test "shows translated title in German", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = Plug.Test.init_test_session(conn, locale: "de")
{:ok, _view, html} = live(conn, "/users")
assert html =~ "Benutzer*innen auflisten"
end
test "shows translated title in English", %{conn: conn} do
conn = conn_with_oidc_user(conn)
Gettext.put_locale(MvWeb.Gettext, "en")
{:ok, _view, html} = live(conn, "/users")
assert html =~ "Listing Users"
end
test "shows New User button", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/users")
assert html =~ "New User"
end
test "displays users in a table", %{conn: conn} do
@tag :ui
test "displays users in a table with basic UI elements", %{conn: conn} do
# Create test users
_user1 = create_test_user(%{email: "alice@example.com", oidc_id: "alice123"})
user1 = create_test_user(%{email: "alice@example.com", oidc_id: "alice123"})
_user2 = create_test_user(%{email: "bob@example.com", oidc_id: "bob456"})
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/users")
# Basic table rendering
assert html =~ "alice@example.com"
assert html =~ "bob@example.com"
end
test "shows correct action links", %{conn: conn} do
user = create_test_user(%{email: "test@example.com"})
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/users")
# UI elements: New User button, action links
assert html =~ "New User"
assert html =~ "Edit"
assert html =~ "Delete"
assert html =~ ~r/href="[^"]*\/users\/#{user.id}\/edit"/
assert html =~ ~r/href="[^"]*\/users\/#{user1.id}\/edit"/
end
@tag :ui
test "shows translated titles in different locales", %{conn: conn} do
# Test German translation
conn = conn_with_oidc_user(conn)
conn = Plug.Test.init_test_session(conn, locale: "de")
{:ok, _view, html_de} = live(conn, "/users")
assert html_de =~ "Benutzer*innen auflisten"
# Test English translation
conn = Plug.Test.init_test_session(conn, locale: "en")
{:ok, _view, html_en} = live(conn, "/users")
assert html_en =~ "Listing Users"
end
end
@ -56,6 +48,7 @@ defmodule MvWeb.UserLive.IndexTest do
%{users: [user_a, user_z, user_m]}
end
@tag :slow
test "initially sorts by email ascending", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/users")
@ -73,6 +66,7 @@ defmodule MvWeb.UserLive.IndexTest do
assert mike_pos < zulu_pos, "mike@example.com should appear before zulu@example.com"
end
@tag :slow
test "can sort email descending by clicking sort button", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/users")
@ -96,28 +90,8 @@ defmodule MvWeb.UserLive.IndexTest do
"mike@example.com should appear before alpha@example.com when sorted desc"
end
test "toggles back to ascending when clicking sort button twice", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/users")
# Click twice to toggle: asc -> desc -> asc
view |> element("button[phx-value-field='email']") |> render_click()
html = view |> element("button[phx-value-field='email']") |> render_click()
# Should be back to ascending
assert html =~ "hero-chevron-up"
assert html =~ ~s(aria-sort="ascending")
# Should be back to original ascending order
alpha_pos = html |> :binary.match("alpha@example.com") |> elem(0)
mike_pos = html |> :binary.match("mike@example.com") |> elem(0)
zulu_pos = html |> :binary.match("zulu@example.com") |> elem(0)
assert alpha_pos < mike_pos, "Should be back to ascending: alpha before mike"
assert mike_pos < zulu_pos, "Should be back to ascending: mike before zulu"
end
test "shows sort direction icons", %{conn: conn} do
@tag :ui
test "toggles sort direction and shows correct icons", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/users")
@ -129,6 +103,19 @@ defmodule MvWeb.UserLive.IndexTest do
view |> element("button[phx-value-field='email']") |> render_click()
html = render(view)
assert html =~ "hero-chevron-down"
# Click again to toggle back to ascending
html = view |> element("button[phx-value-field='email']") |> render_click()
assert html =~ "hero-chevron-up"
assert html =~ ~s(aria-sort="ascending")
# Should be back to original ascending order
alpha_pos = html |> :binary.match("alpha@example.com") |> elem(0)
mike_pos = html |> :binary.match("mike@example.com") |> elem(0)
zulu_pos = html |> :binary.match("zulu@example.com") |> elem(0)
assert alpha_pos < mike_pos, "Should be back to ascending: alpha before mike"
assert mike_pos < zulu_pos, "Should be back to ascending: mike before zulu"
end
end
@ -139,24 +126,23 @@ defmodule MvWeb.UserLive.IndexTest do
%{users: [user1, user2]}
end
test "shows select all checkbox", %{conn: conn} do
@tag :ui
test "shows checkbox UI elements", %{conn: conn, users: [user1, user2]} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/users")
# Check select all checkbox exists
assert html =~ ~s(name="select_all")
assert html =~ ~s(phx-click="select_all")
end
test "shows individual user checkboxes", %{conn: conn, users: [user1, user2]} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/users")
# Check individual user checkboxes exist
assert html =~ ~s(name="#{user1.id}")
assert html =~ ~s(name="#{user2.id}")
assert html =~ ~s(phx-click="select_user")
end
test "can select individual users", %{conn: conn, users: [user1, user2]} do
@tag :ui
test "can select and deselect individual users", %{conn: conn, users: [user1, user2]} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/users")
@ -164,45 +150,31 @@ defmodule MvWeb.UserLive.IndexTest do
assert view |> element("input[type='checkbox'][name='#{user1.id}']") |> has_element?()
assert view |> element("input[type='checkbox'][name='#{user2.id}']") |> has_element?()
# Initially, select_all should not be checked (since no individual items are selected)
refute view
|> element("input[type='checkbox'][name='select_all'][checked]")
|> has_element?()
# Select first user checkbox
html = view |> element("input[type='checkbox'][name='#{user1.id}']") |> render_click()
assert html =~ "Email"
assert html =~ to_string(user1.email)
# The select_all checkbox should still not be checked (not all users selected)
refute view
|> element("input[type='checkbox'][name='select_all'][checked]")
|> has_element?()
# Page should still function normally
assert html =~ "Email"
assert html =~ to_string(user1.email)
end
test "can deselect individual users", %{conn: conn, users: [user1, _user2]} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/users")
# Select user first
view |> element("input[type='checkbox'][name='#{user1.id}']") |> render_click()
# Then deselect user
# Deselect user
html = view |> element("input[type='checkbox'][name='#{user1.id}']") |> render_click()
assert html =~ "Email"
# Select all should not be checked after deselecting individual user
refute view
|> element("input[type='checkbox'][name='select_all'][checked]")
|> has_element?()
# Page should still function normally
assert html =~ "Email"
assert html =~ to_string(user1.email)
end
test "select all functionality selects all users", %{conn: conn, users: [user1, user2]} do
@tag :ui
test "select all and deselect all functionality", %{conn: conn, users: [user1, user2]} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/users")
@ -227,23 +199,9 @@ defmodule MvWeb.UserLive.IndexTest do
|> element("input[type='checkbox'][name='select_all'][checked]")
|> has_element?()
# Page should still function normally and show all users
assert html =~ "Email"
assert html =~ to_string(user1.email)
assert html =~ to_string(user2.email)
end
test "deselect all functionality deselects all users", %{conn: conn, users: [user1, user2]} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/users")
# Select all first
view |> element("input[type='checkbox'][name='select_all']") |> render_click()
# Verify that select_all is checked
assert view
|> element("input[type='checkbox'][name='select_all'][checked]")
|> has_element?()
# Then deselect all
html = view |> element("input[type='checkbox'][name='select_all']") |> render_click()
@ -261,40 +219,47 @@ defmodule MvWeb.UserLive.IndexTest do
|> element("input[type='checkbox'][name='#{user2.id}'][checked]")
|> has_element?()
# Page should still function normally
assert html =~ "Email"
assert html =~ to_string(user1.email)
assert html =~ to_string(user2.email)
end
@tag :slow
test "select all automatically checks when all individual users are selected", %{
conn: conn,
users: [user1, user2]
users: [_user1, _user2]
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/users")
{:ok, view, html} = live(conn, "/users")
# Initially nothing should be checked
refute view
|> element("input[type='checkbox'][name='select_all'][checked]")
|> has_element?()
# Get all user IDs from the rendered HTML by finding all checkboxes with phx-click="select_user"
# Extract user IDs from the HTML (they appear as name attributes on checkboxes)
user_ids =
html
|> String.split("phx-click=\"select_user\"")
|> Enum.flat_map(fn part ->
case Regex.run(~r/name="([^"]+)"[^>]*phx-value-id/, part) do
[_, user_id] -> [user_id]
_ -> []
end
end)
|> Enum.uniq()
# Select first user
view |> element("input[type='checkbox'][name='#{user1.id}']") |> render_click()
# Select all should still not be checked (only 1 of 2+ users selected)
refute view
|> element("input[type='checkbox'][name='select_all'][checked]")
|> has_element?()
# Skip if no users found (shouldn't happen, but be safe)
if user_ids != [] do
# Initially nothing should be checked
refute view
|> element("input[type='checkbox'][name='select_all'][checked]")
|> has_element?()
# Select second user
html = view |> element("input[type='checkbox'][name='#{user2.id}']") |> render_click()
# Select all users one by one
Enum.each(user_ids, fn user_id ->
view |> element("input[type='checkbox'][name='#{user_id}']") |> render_click()
end)
# Now select all should be automatically checked (all individual users are selected)
# Note: This test might need adjustment based on actual implementation
# The logic depends on whether authenticated user is included in the count
assert html =~ "Email"
assert html =~ to_string(user1.email)
assert html =~ to_string(user2.email)
# Now select all should be automatically checked (all individual users are selected)
assert view
|> element("input[type='checkbox'][name='select_all'][checked]")
|> has_element?()
end
end
end
@ -307,11 +272,12 @@ defmodule MvWeb.UserLive.IndexTest do
# Confirm user is displayed
assert render(view) =~ "delete-me@example.com"
# Click the first delete button to test the functionality
# Click the delete button (phx-click="delete" event)
view |> element("tbody tr:first-child a[data-confirm]") |> render_click()
# The page should still render (basic functionality test)
# Verify user was actually deleted (should not appear in HTML anymore)
html = render(view)
refute html =~ "delete-me@example.com"
# Table header should still be there
assert html =~ "Email"
end
@ -327,52 +293,39 @@ defmodule MvWeb.UserLive.IndexTest do
end
describe "navigation" do
test "clicking on user row navigates to user show page", %{conn: conn} do
@tag :ui
test "navigation links point to correct pages", %{conn: conn} do
user = create_test_user(%{email: "navigate@example.com"})
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/users")
{:ok, view, html} = live(conn, "/users")
# This test would need to check row click behavior
# The actual navigation would happen via JavaScript
html = render(view)
# Check that user row contains link to show page
assert html =~ ~s(/users/#{user.id})
end
test "edit link points to correct edit page", %{conn: conn} do
user = create_test_user(%{email: "edit-me@example.com"})
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/users")
# Check edit link points to correct edit page
assert html =~ ~s(/users/#{user.id}/edit)
end
test "new user button points to correct new page", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/users")
# Check new user button points to correct new page
assert html =~ ~s(/users/new)
end
end
describe "translations" do
test "shows German translations for selection", %{conn: conn} do
@tag :ui
test "shows translations for selection in different locales", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Test German translations
conn = Plug.Test.init_test_session(conn, locale: "de")
{:ok, _view, html} = live(conn, "/users")
{:ok, _view, html_de} = live(conn, "/users")
assert html_de =~ "Alle Benutzer*innen auswählen"
assert html_de =~ "Benutzer*in auswählen"
assert html =~ "Alle Benutzer*innen auswählen"
assert html =~ "Benutzer*in auswählen"
end
test "shows English translations for selection", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Test English translations
Gettext.put_locale(MvWeb.Gettext, "en")
{:ok, _view, html} = live(conn, "/users")
# Note: English translations might be empty strings by default
# This test would verify the structure is there
# Checking that aria-label attributes exist
assert html =~ ~s(aria-label=)
{:ok, _view, html_en} = live(conn, "/users")
# Check that aria-label attributes exist (structure is there)
assert html_en =~ ~s(aria-label=)
end
end
@ -427,6 +380,7 @@ defmodule MvWeb.UserLive.IndexTest do
end
describe "member linking display" do
@tag :slow
test "displays linked member name in user list", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()

View file

@ -1,44 +1,81 @@
defmodule Mv.SeedsTest do
@moduledoc """
Minimal test suite for database seeds.
This test suite focuses on critical deployment requirements:
- Seeds run without errors (smoke test)
- Seeds are idempotent (can be run multiple times)
- Critical bootstrap invariants are met (Admin user, system roles)
## Removed Tests
The following tests were removed to reduce test execution time.
Their functionality is covered by domain-specific tests:
- Member/fee type distribution tests covered by `membership_fees/*_test.exs`
- Cycle status variation tests covered by `membership_fees/cycle_generator_test.exs`
- Detailed role configuration tests covered by `authorization/*_test.exs`
- Permission set validation covered by `authorization/permission_sets_test.exs`
See `docs/test-optimization-seeds.md` for detailed rationale and coverage mapping.
"""
use Mv.DataCase, async: false
require Ash.Query
# Module attribute to track if seeds have been run
# This allows us to run seeds once per test module while respecting sandbox isolation
@seeds_run_key :seeds_test_run
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Run seeds once per test process (due to sandbox isolation, each test runs in its own process)
# Process.get/put ensures seeds are only executed once per test process, not once per test
# Note: With async: false and sandbox isolation, this effectively runs seeds once per test,
# but the guard prevents multiple executions within the same test process if setup is called multiple times
unless Process.get(@seeds_run_key) do
Code.eval_file("priv/repo/seeds.exs")
Process.put(@seeds_run_key, true)
end
%{actor: system_actor}
end
describe "Seeds script" do
test "runs successfully without errors", %{actor: actor} do
# Run the seeds script - should not raise any errors
assert Code.eval_file("priv/repo/seeds.exs")
# Basic smoke test: ensure some data was created
@tag :slow
test "runs successfully and creates basic data", %{actor: actor} do
# Smoke test: verify seeds created essential data structures
{:ok, users} = Ash.read(Mv.Accounts.User, actor: actor)
{:ok, members} = Ash.read(Mv.Membership.Member, actor: actor)
{:ok, custom_fields} = Ash.read(Mv.Membership.CustomField, actor: actor)
{:ok, roles} = Ash.read(Mv.Authorization.Role, domain: Mv.Authorization, authorize?: false)
assert not Enum.empty?(users), "Seeds should create at least one user"
assert not Enum.empty?(members), "Seeds should create at least one member"
assert not Enum.empty?(custom_fields), "Seeds should create at least one custom field"
assert length(roles) >= 5, "Seeds should create at least 5 authorization roles"
end
test "can be run multiple times (idempotent)", %{actor: actor} do
# Run seeds first time
assert Code.eval_file("priv/repo/seeds.exs")
# Count records
@tag :slow
test "is idempotent when run multiple times", %{actor: actor} do
# Seeds already run once in setup - count current state
{:ok, users_count_1} = Ash.read(Mv.Accounts.User, actor: actor)
{:ok, members_count_1} = Ash.read(Mv.Membership.Member, actor: actor)
{:ok, custom_fields_count_1} = Ash.read(Mv.Membership.CustomField, actor: actor)
# Run seeds second time - should not raise errors
{:ok, roles_count_1} =
Ash.read(Mv.Authorization.Role, domain: Mv.Authorization, authorize?: false)
# Run seeds again - should not raise errors and should be idempotent
assert Code.eval_file("priv/repo/seeds.exs")
# Count records again - should be the same (upsert, not duplicate)
{:ok, users_count_2} = Ash.read(Mv.Accounts.User, actor: actor)
{:ok, members_count_2} = Ash.read(Mv.Membership.Member, actor: actor)
{:ok, custom_fields_count_2} = Ash.read(Mv.Membership.CustomField, actor: actor)
{:ok, roles_count_2} =
Ash.read(Mv.Authorization.Role, domain: Mv.Authorization, authorize?: false)
assert length(users_count_1) == length(users_count_2),
"Users count should remain same after re-running seeds"
@ -46,215 +83,45 @@ defmodule Mv.SeedsTest do
assert length(members_count_1) == length(members_count_2),
"Members count should remain same after re-running seeds"
assert length(custom_fields_count_1) == length(custom_fields_count_2),
"CustomFields count should remain same after re-running seeds"
end
test "at least one member has no membership fee type assigned", %{actor: actor} do
# Run the seeds script
assert Code.eval_file("priv/repo/seeds.exs")
# Get all members
{:ok, members} = Ash.read(Mv.Membership.Member, actor: actor)
# At least one member should have no membership_fee_type_id
members_without_fee_type =
Enum.filter(members, fn member -> member.membership_fee_type_id == nil end)
assert not Enum.empty?(members_without_fee_type),
"At least one member should have no membership fee type assigned"
end
test "each membership fee type has at least one member", %{actor: actor} do
# Run the seeds script
assert Code.eval_file("priv/repo/seeds.exs")
# Get all fee types and members
{:ok, fee_types} = Ash.read(Mv.MembershipFees.MembershipFeeType, actor: actor)
{:ok, members} = Ash.read(Mv.Membership.Member, actor: actor)
# Group members by fee type (excluding nil)
members_by_fee_type =
members
|> Enum.filter(&(&1.membership_fee_type_id != nil))
|> Enum.group_by(& &1.membership_fee_type_id)
# Each fee type should have at least one member
Enum.each(fee_types, fn fee_type ->
members_for_type = Map.get(members_by_fee_type, fee_type.id, [])
assert not Enum.empty?(members_for_type),
"Membership fee type #{fee_type.name} should have at least one member assigned"
end)
end
test "members with fee types have cycles with various statuses", %{actor: actor} do
# Run the seeds script
assert Code.eval_file("priv/repo/seeds.exs")
# Get all members with fee types
{:ok, members} = Ash.read(Mv.Membership.Member, actor: actor)
members_with_fee_types =
members
|> Enum.filter(&(&1.membership_fee_type_id != nil))
# At least one member should have cycles
assert not Enum.empty?(members_with_fee_types),
"At least one member should have a membership fee type"
# Check that cycles exist and have various statuses
all_cycle_statuses =
members_with_fee_types
|> Enum.flat_map(fn member ->
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!(actor: actor)
end)
|> Enum.map(& &1.status)
# At least one cycle should be paid
assert :paid in all_cycle_statuses, "At least one cycle should be paid"
# At least one cycle should be unpaid
assert :unpaid in all_cycle_statuses, "At least one cycle should be unpaid"
# At least one cycle should be suspended
assert :suspended in all_cycle_statuses, "At least one cycle should be suspended"
assert length(roles_count_1) == length(roles_count_2),
"Roles count should remain same after re-running seeds"
end
end
describe "Authorization roles (from seeds)" do
test "creates all 5 authorization roles with correct permission sets" do
# Run seeds once for this test
Code.eval_file("priv/repo/seeds.exs")
{:ok, roles} = Ash.read(Mv.Authorization.Role, domain: Mv.Authorization, authorize?: false)
describe "Critical bootstrap invariants" do
test "Admin user has Admin role and can authenticate", %{actor: _actor} do
# Critical: Without this, initial system setup fails
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
assert length(roles) >= 5, "Should have at least 5 roles"
{:ok, admin_user} =
Mv.Accounts.User
|> Ash.Query.filter(email == ^admin_email)
|> Ash.read_one(domain: Mv.Accounts, authorize?: false)
# Check each role
role_configs = [
{"Mitglied", "own_data", true},
{"Vorstand", "read_only", false},
{"Kassenwart", "normal_user", false},
{"Buchhaltung", "read_only", false},
{"Admin", "admin", false}
]
assert admin_user != nil, "Admin user must exist after seeds run"
Enum.each(role_configs, fn {name, perm_set, is_system} ->
role = Enum.find(roles, &(&1.name == name))
assert role, "Role #{name} should exist"
assert role.permission_set_name == perm_set
assert role.is_system_role == is_system
end)
{:ok, admin_user_with_role} =
Ash.load(admin_user, :role, domain: Mv.Accounts, authorize?: false)
assert admin_user_with_role.role != nil, "Admin user must have a role assigned"
assert admin_user_with_role.role.name == "Admin", "Admin user must have Admin role"
assert admin_user_with_role.role.permission_set_name == "admin",
"Admin role must have admin permission set"
end
test "Mitglied role is marked as system role" do
Code.eval_file("priv/repo/seeds.exs")
test "Mitglied system role exists and is correctly configured" do
# Critical: Default role for new users - system breaks without this
{:ok, mitglied} =
Mv.Authorization.Role
|> Ash.Query.filter(name == "Mitglied")
|> Ash.read_one(domain: Mv.Authorization, authorize?: false)
assert mitglied.is_system_role == true
end
assert mitglied != nil, "Mitglied role must exist"
assert mitglied.is_system_role == true, "Mitglied role must be marked as system role"
test "all roles have valid permission_set_names" do
Code.eval_file("priv/repo/seeds.exs")
{:ok, roles} = Ash.read(Mv.Authorization.Role, domain: Mv.Authorization, authorize?: false)
valid_sets =
Mv.Authorization.PermissionSets.all_permission_sets()
|> Enum.map(&Atom.to_string/1)
Enum.each(roles, fn role ->
assert role.permission_set_name in valid_sets,
"Role #{role.name} has invalid permission_set_name: #{role.permission_set_name}"
end)
end
test "assigns Admin role to ADMIN_EMAIL user" do
Code.eval_file("priv/repo/seeds.exs")
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
{:ok, admin_user} =
Mv.Accounts.User
|> Ash.Query.filter(email == ^admin_email)
|> Ash.read_one(domain: Mv.Accounts, authorize?: false)
assert admin_user != nil, "Admin user should exist after seeds run"
{:ok, admin_user_with_role} =
Ash.load(admin_user, :role, domain: Mv.Accounts, authorize?: false)
assert admin_user_with_role.role != nil, "Admin user should have a role assigned"
assert admin_user_with_role.role.name == "Admin"
assert admin_user_with_role.role.permission_set_name == "admin"
end
end
describe "Authorization role assignment" do
test "does not change role of users who already have a role" do
# Seeds once (creates Admin with Admin role)
Code.eval_file("priv/repo/seeds.exs")
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
{:ok, admin_user} =
Mv.Accounts.User
|> Ash.Query.filter(email == ^admin_email)
|> Ash.read_one(domain: Mv.Accounts, authorize?: false)
assert admin_user != nil, "Admin user should exist after seeds run"
{:ok, admin_user_with_role} =
Ash.load(admin_user, :role, domain: Mv.Accounts, authorize?: false)
assert admin_user_with_role.role != nil, "Admin user should have a role assigned"
original_role_id = admin_user_with_role.role_id
assert admin_user_with_role.role.name == "Admin"
# Seeds again
Code.eval_file("priv/repo/seeds.exs")
# Admin reloaded
{:ok, admin_reloaded} =
Mv.Accounts.User
|> Ash.Query.filter(email == ^admin_email)
|> Ash.read_one(domain: Mv.Accounts, authorize?: false)
assert admin_reloaded != nil, "Admin user should still exist after re-running seeds"
{:ok, admin_reloaded_with_role} =
Ash.load(admin_reloaded, :role, domain: Mv.Accounts, authorize?: false)
assert admin_reloaded_with_role.role != nil,
"Admin user should still have a role after re-running seeds"
assert admin_reloaded_with_role.role_id == original_role_id
assert admin_reloaded_with_role.role.name == "Admin"
end
test "role creation is idempotent" do
Code.eval_file("priv/repo/seeds.exs")
{:ok, roles_1} =
Ash.read(Mv.Authorization.Role, domain: Mv.Authorization, authorize?: false)
Code.eval_file("priv/repo/seeds.exs")
{:ok, roles_2} =
Ash.read(Mv.Authorization.Role, domain: Mv.Authorization, authorize?: false)
assert length(roles_1) == length(roles_2),
"Role count should remain same after re-running seeds"
# Each role should appear exactly once
role_names = Enum.map(roles_2, & &1.name)
assert length(role_names) == length(Enum.uniq(role_names)),
"Each role name should appear exactly once"
assert mitglied.permission_set_name == "own_data",
"Mitglied role must have own_data permission set"
end
end
end

View file

@ -1,2 +1,6 @@
ExUnit.start()
ExUnit.start(
# shows 10 slowest tests at the end of the test run
# slowest: 10
)
Ecto.Adapters.SQL.Sandbox.mode(Mv.Repo, :manual)