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 kind: pipeline
type: docker type: docker
name: check name: check-fast
services: services:
- name: postgres - name: postgres
@ -72,7 +72,7 @@ steps:
echo "Postgres did not become available, aborting." echo "Postgres did not become available, aborting."
exit 1 exit 1
- name: test - name: test-fast
image: docker.io/library/elixir:1.18.3-otp-27 image: docker.io/library/elixir:1.18.3-otp-27
environment: environment:
MIX_ENV: test MIX_ENV: test
@ -83,7 +83,114 @@ steps:
- mix local.hex --force - mix local.hex --force
# Fetch dependencies # Fetch dependencies
- mix deps.get - 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 - mix test
- name: rebuild-cache - name: rebuild-cache
@ -147,7 +254,7 @@ steps:
- push - push
depends_on: depends_on:
- check - check-fast
--- ---
kind: pipeline 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 - Falls back to admin user from seeds if system user doesn't exist
- Should NEVER be used for user-initiated actions (only systemic operations) - 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 vs System Mode:**
- **User Mode**: User-initiated actions use the actual user actor, policies are enforced - **User Mode**: User-initiated actions use the actual user actor, policies are enforced
@ -1654,10 +1661,87 @@ end
- Mock external services - Mock external services
- Use fixtures efficiently - 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. 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 ### 5.1 Authentication & Authorization
**Use AshAuthentication:** **Use AshAuthentication:**

View file

@ -22,7 +22,7 @@ seed-database:
start-database: start-database:
docker compose up -d docker compose up -d
ci-dev: lint audit test ci-dev: lint audit test-fast
gettext: gettext:
mix gettext.extract mix gettext.extract
@ -41,9 +41,30 @@ audit:
mix deps.audit mix deps.audit
mix hex.audit mix hex.audit
# Run all tests
test *args: install-dependencies test *args: install-dependencies
mix test {{args}} 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: format:
mix format mix format

View file

@ -1101,28 +1101,23 @@ end
### CustomField Resource Policies ### 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. **No Special Cases:** All users can read, only admin can write.
```elixir ```elixir
defmodule Mv.Membership.CustomField do defmodule Mv.Membership.CustomField do
use Ash.Resource, ... use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
policies do 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 policy action_type([:read, :create, :update, :destroy]) do
description "Check permissions from user's role" description "Check permissions from user's role"
authorize_if Mv.Authorization.Checks.HasPermission authorize_if Mv.Authorization.Checks.HasPermission
end end
# DEFAULT: Forbid
policy action_type([:read, :create, :update, :destroy]) do
forbid_if always()
end
end 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, use Ash.Resource,
domain: Mv.Membership, domain: Mv.Membership,
data_layer: AshPostgres.DataLayer data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
postgres do postgres do
table "custom_fields" table "custom_fields"
@ -79,6 +80,13 @@ defmodule Mv.Membership.CustomField do
end end
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 attributes do
uuid_primary_key :id uuid_primary_key :id

View file

@ -471,11 +471,12 @@ defmodule Mv.Membership.Member do
end end
end end
# Validate required custom fields # Validate required custom fields (actor from validation context only; no fallback)
validate fn changeset, _ -> validate fn changeset, context ->
provided_values = provided_custom_field_values(changeset) 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} -> {:ok, required_custom_fields} ->
missing_fields = missing_required_fields(required_custom_fields, provided_values) 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) build_custom_field_validation_error(missing_fields)
end 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} -> {:error, error} ->
Logger.error( Logger.error(
"Failed to load custom fields for validation: #{inspect(error)}. Required field validation cannot be performed." "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: The domain exposes these main actions:
- Member CRUD: `create_member/1`, `list_members/0`, `update_member/2`, `destroy_member/1` - 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 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` - 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` - 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` - 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. Lists only required custom fields.
This is an optimized version that filters at the database level instead of 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 ## Returns
- `{:ok, required_custom_fields}` - List of required custom fields - `{: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 ## 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) iex> Enum.all?(required_fields, & &1.required)
true 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 Mv.Membership.CustomField
|> Ash.Query.filter(expr(required == true)) |> Ash.Query.filter(expr(required == true))
|> Ash.read(domain: __MODULE__) |> Ash.read(domain: __MODULE__, actor: actor)
end end
def list_required_custom_fields(actor: nil), do: {:error, :missing_actor}
@doc """ @doc """
Updates the member field visibility configuration. Updates the member field visibility configuration.

View file

@ -91,7 +91,8 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
@impl true @impl true
def handle_event("save", %{"custom_field" => custom_field_params}, socket) do 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 case MvWeb.LiveHelpers.submit_form(socket.assigns.form, custom_field_params, actor) do
{:ok, custom_field} -> {:ok, custom_field} ->

View file

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

View file

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

View file

@ -113,16 +113,22 @@ defmodule MvWeb.GroupLive.Index do
defp load_groups(actor) do defp load_groups(actor) do
require Ash.Query require Ash.Query
# Load groups without aggregates first (faster)
query = query =
Mv.Membership.Group Mv.Membership.Group
|> Ash.Query.load(:member_count)
|> Ash.Query.sort(:name) |> Ash.Query.sort(:name)
opts = ash_actor_opts(actor) opts = ash_actor_opts(actor)
case Ash.read(query, opts) do case Ash.read(query, opts) do
{:ok, groups} -> {: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} -> {:error, _error} ->
require Logger require Logger
@ -130,4 +136,33 @@ defmodule MvWeb.GroupLive.Index do
[] []
end end
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 end

View file

@ -38,7 +38,16 @@ defmodule MvWeb.GroupLive.Show do
end end
defp load_group_by_slug(socket, slug, actor) do 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} -> {:ok, nil} ->
{:noreply, {:noreply,
socket socket

View file

@ -226,7 +226,7 @@ defmodule MvWeb.MemberLive.Form do
def mount(params, _session, socket) do def mount(params, _session, socket) do
# current_user should be set by on_mount hooks (LiveUserAuth + LiveHelpers) # current_user should be set by on_mount hooks (LiveUserAuth + LiveHelpers)
actor = current_actor(socket) 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 = initial_custom_field_values =
Enum.map(custom_fields, fn cf -> 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) {new_field, new_order} = determine_new_sort(field, socket)
old_field = socket.assigns.sort_field
socket 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) |> push_sort_url(new_field, new_order)
end end

View file

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

View file

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

View file

@ -2262,3 +2262,13 @@ msgstr "Dieser Benutzer kann nicht bearbeitet werden."
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "This user cannot be viewed." msgid "This user cannot be viewed."
msgstr "Dieser Benutzer kann nicht angezeigt werden." 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 #, elixir-autogen, elixir-format
msgid "Not authorized." msgid "Not authorized."
msgstr "" 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 #, elixir-autogen, elixir-format
msgid "This user cannot be viewed." msgid "This user cannot be viewed."
msgstr "" 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 required: false
} }
] do ] do
# Bootstrap: no admin user yet; CustomField create requires admin, so skip authorization
Membership.create_custom_field!( Membership.create_custom_field!(
attrs, attrs,
upsert?: true, upsert?: true,
upsert_identity: :unique_name upsert_identity: :unique_name,
authorize?: false
) )
end end
@ -379,12 +381,16 @@ Enum.each(member_attrs_list, fn member_attrs ->
final_member = final_member =
if is_nil(member.membership_fee_type_id) and if is_nil(member.membership_fee_type_id) and
Map.has_key?(member_attrs_without_status, :membership_fee_type_id) do Map.has_key?(member_attrs_without_status, :membership_fee_type_id) do
member {:ok, updated} =
|> Ash.Changeset.for_update(:update_member, %{ Membership.update_member(
membership_fee_type_id: member_attrs_without_status.membership_fee_type_id member,
}) %{
|> Ash.Changeset.put_context(:actor, admin_user_with_role) membership_fee_type_id: member_attrs_without_status.membership_fee_type_id
|> Ash.update!(actor: admin_user_with_role) },
actor: admin_user_with_role
)
updated
else else
member member
end end
@ -544,9 +550,12 @@ Enum.with_index(linked_members)
fee_type_index = rem(3 + index, length(all_fee_types)) fee_type_index = rem(3 + index, length(all_fee_types))
fee_type = Enum.at(all_fee_types, fee_type_index) fee_type = Enum.at(all_fee_types, fee_type_index)
member {:ok, updated} =
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id}) Membership.update_member(member, %{membership_fee_type_id: fee_type.id},
|> Ash.update!(actor: admin_user_with_role) actor: admin_user_with_role
)
updated
else else
member member
end end
@ -594,7 +603,7 @@ end)
# Create sample custom field values for some members # Create sample custom field values for some members
all_members = Ash.read!(Membership.Member, actor: admin_user_with_role) 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 # Helper function to find custom field by name
find_field = fn name -> Enum.find(all_custom_fields, &(&1.name == name)) end 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 # Helper functions
defp create_member(actor) do defp create_member(actor) do
Member Mv.Membership.create_member(
|> Ash.Changeset.for_create(:create_member, %{ %{
first_name: "Test", first_name: "Test",
last_name: "User#{System.unique_integer([:positive])}", last_name: "User#{System.unique_integer([:positive])}",
email: "test#{System.unique_integer([:positive])}@example.com" email: "test#{System.unique_integer([:positive])}@example.com"
}) },
|> Ash.create(actor: actor) actor: actor
)
end end
defp create_custom_field(name, value_type, actor) do defp create_custom_field(name, value_type, actor) do

View file

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

View file

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

View file

@ -130,6 +130,7 @@ defmodule Mv.Membership.MemberAvailableForLinkingTest do
end) end)
end end
@tag :slow
test "limits results to 10 members even when more exist" do test "limits results to 10 members even when more exist" do
system_actor = Mv.Helpers.SystemActor.get_system_actor() 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) attrs = Map.merge(default_attrs, attrs)
{:ok, member} = Mv.Membership.create_member(attrs, actor: actor)
Member member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!(actor: actor)
end end
# Helper to create a cycle # Helper to create a cycle

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -37,10 +37,8 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
} }
attrs = Map.merge(default_attrs, attrs) attrs = Map.merge(default_attrs, attrs)
{:ok, member} = Mv.Membership.create_member(attrs, actor: actor)
Member member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!(actor: actor)
end end
# Helper to create a cycle # 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 # async: false because we need database commits to be visible across queries
use Mv.DataCase, async: false use Mv.DataCase, async: false
alias Mv.Membership.{CustomField, CustomFieldValue, Member} alias Mv.Membership.{CustomField, CustomFieldValue}
alias Mv.Accounts alias Mv.Accounts
alias Mv.Authorization alias Mv.Authorization
@ -62,13 +62,14 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
defp create_linked_member_for_user(user, actor) do defp create_linked_member_for_user(user, actor) do
{:ok, member} = {:ok, member} =
Member Mv.Membership.create_member(
|> Ash.Changeset.for_create(:create_member, %{ %{
first_name: "Linked", first_name: "Linked",
last_name: "Member", last_name: "Member",
email: "linked#{System.unique_integer([:positive])}@example.com" email: "linked#{System.unique_integer([:positive])}@example.com"
}) },
|> Ash.create(actor: actor, return_notifications?: false) actor: actor
)
user user
|> Ash.Changeset.for_update(:update, %{}) |> Ash.Changeset.for_update(:update, %{})
@ -80,13 +81,14 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
defp create_unlinked_member(actor) do defp create_unlinked_member(actor) do
{:ok, member} = {:ok, member} =
Member Mv.Membership.create_member(
|> Ash.Changeset.for_create(:create_member, %{ %{
first_name: "Unlinked", first_name: "Unlinked",
last_name: "Member", last_name: "Member",
email: "unlinked#{System.unique_integer([:positive])}@example.com" email: "unlinked#{System.unique_integer([:positive])}@example.com"
}) },
|> Ash.create(actor: actor) actor: actor
)
member member
end 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 # 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. # before we try to link it. Ash may delay writes, so we explicitly return the struct.
{:ok, member} = {:ok, member} =
Membership.Member Membership.create_member(
|> Ash.Changeset.for_create(:create_member, %{ %{
first_name: "Linked", first_name: "Linked",
last_name: "Member", last_name: "Member",
email: "linked#{System.unique_integer([:positive])}@example.com" email: "linked#{System.unique_integer([:positive])}@example.com"
}) },
|> Ash.create(actor: admin, return_notifications?: false) actor: admin
)
# Link member to user (User.member_id = member.id) # Link member to user (User.member_id = member.id)
# We use force_change_attribute because the member already exists and we just # 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) admin = create_admin_user(actor)
{:ok, member} = {:ok, member} =
Membership.Member Membership.create_member(
|> Ash.Changeset.for_create(:create_member, %{ %{
first_name: "Unlinked", first_name: "Unlinked",
last_name: "Member", last_name: "Member",
email: "unlinked#{System.unique_integer([:positive])}@example.com" email: "unlinked#{System.unique_integer([:positive])}@example.com"
}) },
|> Ash.create(actor: admin) actor: admin
)
member member
end end
@ -145,9 +147,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
# Update is allowed via HasPermission check with :linked scope (not via special case) # Update is allowed via HasPermission check with :linked scope (not via special case)
# The special case policy only applies to :read actions # The special case policy only applies to :read actions
{:ok, updated_member} = {:ok, updated_member} =
linked_member Membership.update_member(linked_member, %{first_name: "Updated"}, actor: user)
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update(actor: user)
assert updated_member.first_name == "Updated" assert updated_member.first_name == "Updated"
end end
@ -168,11 +168,8 @@ defmodule Mv.Membership.MemberPoliciesTest do
user: user, user: user,
unlinked_member: unlinked_member unlinked_member: unlinked_member
} do } do
assert_raise Ash.Error.Forbidden, fn -> assert {:error, %Ash.Error.Forbidden{}} =
unlinked_member Membership.update_member(unlinked_member, %{first_name: "Updated"}, actor: user)
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update!(actor: user)
end
end end
test "list members returns only linked member", %{ test "list members returns only linked member", %{
@ -187,15 +184,15 @@ defmodule Mv.Membership.MemberPoliciesTest do
end end
test "cannot create member (returns forbidden)", %{user: user} do test "cannot create member (returns forbidden)", %{user: user} do
assert_raise Ash.Error.Forbidden, fn -> assert {:error, %Ash.Error.Forbidden{}} =
Membership.Member Membership.create_member(
|> Ash.Changeset.for_create(:create_member, %{ %{
first_name: "New", first_name: "New",
last_name: "Member", last_name: "Member",
email: "new#{System.unique_integer([:positive])}@example.com" email: "new#{System.unique_integer([:positive])}@example.com"
}) },
|> Ash.create!(actor: user) actor: user
end )
end end
test "cannot destroy member (returns forbidden)", %{ test "cannot destroy member (returns forbidden)", %{
@ -245,26 +242,23 @@ defmodule Mv.Membership.MemberPoliciesTest do
end end
test "cannot create member (returns forbidden)", %{user: user} do test "cannot create member (returns forbidden)", %{user: user} do
assert_raise Ash.Error.Forbidden, fn -> assert {:error, %Ash.Error.Forbidden{}} =
Membership.Member Membership.create_member(
|> Ash.Changeset.for_create(:create_member, %{ %{
first_name: "New", first_name: "New",
last_name: "Member", last_name: "Member",
email: "new#{System.unique_integer([:positive])}@example.com" email: "new#{System.unique_integer([:positive])}@example.com"
}) },
|> Ash.create!(actor: user) actor: user
end )
end end
test "cannot update any member (returns forbidden)", %{ test "cannot update any member (returns forbidden)", %{
user: user, user: user,
linked_member: linked_member linked_member: linked_member
} do } do
assert_raise Ash.Error.Forbidden, fn -> assert {:error, %Ash.Error.Forbidden{}} =
linked_member Membership.update_member(linked_member, %{first_name: "Updated"}, actor: user)
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update!(actor: user)
end
end end
test "cannot destroy any member (returns forbidden)", %{ test "cannot destroy any member (returns forbidden)", %{
@ -305,22 +299,21 @@ defmodule Mv.Membership.MemberPoliciesTest do
test "can create member", %{user: user} do test "can create member", %{user: user} do
{:ok, member} = {:ok, member} =
Membership.Member Membership.create_member(
|> Ash.Changeset.for_create(:create_member, %{ %{
first_name: "New", first_name: "New",
last_name: "Member", last_name: "Member",
email: "new#{System.unique_integer([:positive])}@example.com" email: "new#{System.unique_integer([:positive])}@example.com"
}) },
|> Ash.create(actor: user) actor: user
)
assert member.first_name == "New" assert member.first_name == "New"
end end
test "can update any member", %{user: user, unlinked_member: unlinked_member} do test "can update any member", %{user: user, unlinked_member: unlinked_member} do
{:ok, updated_member} = {:ok, updated_member} =
unlinked_member Membership.update_member(unlinked_member, %{first_name: "Updated"}, actor: user)
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update(actor: user)
assert updated_member.first_name == "Updated" assert updated_member.first_name == "Updated"
end end
@ -363,22 +356,21 @@ defmodule Mv.Membership.MemberPoliciesTest do
test "can create member", %{user: user} do test "can create member", %{user: user} do
{:ok, member} = {:ok, member} =
Membership.Member Membership.create_member(
|> Ash.Changeset.for_create(:create_member, %{ %{
first_name: "New", first_name: "New",
last_name: "Member", last_name: "Member",
email: "new#{System.unique_integer([:positive])}@example.com" email: "new#{System.unique_integer([:positive])}@example.com"
}) },
|> Ash.create(actor: user) actor: user
)
assert member.first_name == "New" assert member.first_name == "New"
end end
test "can update any member", %{user: user, unlinked_member: unlinked_member} do test "can update any member", %{user: user, unlinked_member: unlinked_member} do
{:ok, updated_member} = {:ok, updated_member} =
unlinked_member Membership.update_member(unlinked_member, %{first_name: "Updated"}, actor: user)
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update(actor: user)
assert updated_member.first_name == "Updated" assert updated_member.first_name == "Updated"
end end
@ -456,9 +448,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
# Should succeed via HasPermission check (not special case) # Should succeed via HasPermission check (not special case)
{:ok, updated_member} = {:ok, updated_member} =
linked_member Membership.update_member(linked_member, %{first_name: "Updated"}, actor: user)
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update(actor: user)
assert updated_member.first_name == "Updated" assert updated_member.first_name == "Updated"
end end

View file

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

View file

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

View file

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

View file

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

View file

@ -15,13 +15,15 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
use MvWeb.ConnCase, async: true use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{CustomField, CustomFieldValue, Member} alias Mv.Membership.{CustomField, CustomFieldValue, Member}
setup do setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor() 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} = {:ok, user} =
Mv.Accounts.User Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{ |> Ash.Changeset.for_create(:register_with_password, %{
@ -30,8 +32,18 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
}) })
|> Ash.create(actor: system_actor) |> Ash.create(actor: system_actor)
conn = log_in_user(build_conn(), user) {:ok, user} =
%{conn: conn, user: 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 end
describe "delete button and modal" do describe "delete button and modal" do
@ -107,9 +119,8 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|> element("#delete-custom-field-modal form") |> element("#delete-custom-field-modal form")
|> render_change(%{"slug" => custom_field.slug}) |> render_change(%{"slug" => custom_field.slug})
# Confirm button should be enabled now (no disabled attribute) # Confirm button should be enabled now (no disabled attribute on the confirm button)
html = render(view) refute has_element?(view, "#delete-custom-field-modal button.btn-error[disabled]")
refute html =~ ~r/disabled(?:=""|(?!\w))/
end end
test "delete button is disabled when slug doesn't match", %{conn: conn} do 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") |> element("#delete-custom-field-modal form")
|> render_change(%{"slug" => "wrong-slug"}) |> render_change(%{"slug" => "wrong-slug"})
# Button should be disabled # Confirm button should be disabled
html = render(view) assert has_element?(view, "#delete-custom-field-modal button.btn-error[disabled]")
assert html =~ ~r/disabled(?:=""|(?!\w))/
end end
end end
@ -186,10 +196,8 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|> element("#delete-custom-field-modal form") |> element("#delete-custom-field-modal form")
|> render_change(%{"slug" => "wrong-slug"}) |> render_change(%{"slug" => "wrong-slug"})
# Button should be disabled and we cannot click it # Confirm button should be disabled and we cannot click it
# The test verifies that the button is properly disabled in the UI assert has_element?(view, "#delete-custom-field-modal button.btn-error[disabled]")
html = render(view)
assert html =~ ~r/disabled(?:=""|(?!\w))/
# Custom field should still exist since deletion couldn't proceed # Custom field should still exist since deletion couldn't proceed
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = Mv.Helpers.SystemActor.get_system_actor()
@ -224,17 +232,58 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
end end
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 defp create_member do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = Mv.Helpers.SystemActor.get_system_actor()
Member Mv.Membership.create_member(
|> Ash.Changeset.for_create(:create_member, %{ %{
first_name: "Test", first_name: "Test",
last_name: "User#{System.unique_integer([:positive])}", last_name: "User#{System.unique_integer([:positive])}",
email: "test#{System.unique_integer([:positive])}@example.com" email: "test#{System.unique_integer([:positive])}@example.com"
}) },
|> Ash.create(actor: system_actor) actor: system_actor
)
end end
defp create_custom_field(name, value_type) do defp create_custom_field(name, value_type) do

View file

@ -115,15 +115,35 @@ defmodule MvWeb.GroupLive.IndexTest do
end end
describe "performance" do describe "performance" do
@describetag :slow
test "page loads efficiently with many groups", %{conn: conn} do test "page loads efficiently with many groups", %{conn: conn} do
# Create multiple groups # Create multiple groups
Enum.each(1..10, fn _ -> Fixtures.group_fixture() end) 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 # Should load without N+1 queries
{:ok, _view, html} = live(conn, "/groups") {:ok, _view, html} = live(conn, "/groups")
final_count = Agent.get(query_count_agent, & &1)
:telemetry.detach(handler_id)
# Verify all groups are displayed # Verify all groups are displayed
assert html =~ gettext("Groups") 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 end
test "member count is loaded efficiently via calculation", %{conn: conn} do test "member count is loaded efficiently via calculation", %{conn: conn} do
@ -142,10 +162,29 @@ defmodule MvWeb.GroupLive.IndexTest do
actor: system_actor 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") {: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) # Member count should be displayed (should be 2)
assert html =~ "2" or html =~ gettext("Members") or html =~ "Mitglieder" 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 end
end end

View file

@ -220,6 +220,7 @@ defmodule MvWeb.GroupLive.ShowTest do
end end
describe "performance" do describe "performance" do
@describetag :slow
test "member list is loaded efficiently (no N+1 queries)", %{conn: conn} do test "member list is loaded efficiently (no N+1 queries)", %{conn: conn} do
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
@ -235,12 +236,32 @@ defmodule MvWeb.GroupLive.ShowTest do
) )
end) 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}") {: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 # All members should be displayed
Enum.each(members, fn member -> Enum.each(members, fn member ->
assert html =~ member.first_name or html =~ member.last_name assert html =~ member.first_name or html =~ member.last_name
end) 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 end
test "slug lookup is efficient (uses unique_slug index)", %{conn: conn} do 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 import Phoenix.LiveViewTest
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership.Member
require Ash.Query require Ash.Query
@ -55,10 +54,8 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
} }
attrs = Map.merge(default_attrs, attrs) attrs = Map.merge(default_attrs, attrs)
{:ok, member} = Mv.Membership.create_member(attrs, actor: system_actor)
Member member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!(actor: system_actor)
end end
describe "create form" do describe "create form" do

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,33 +3,29 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
require Ash.Query require Ash.Query
alias Mv.Membership.Member
setup do setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, member1} = {:ok, member1} =
Member Mv.Membership.create_member(
|> Ash.Changeset.for_create(:create_member, %{ %{
first_name: "Alice", first_name: "Alice",
last_name: "Anderson", last_name: "Anderson",
email: "alice@example.com", email: "alice@example.com",
street: "Main Street", street: "Main Street",
house_number: "123", house_number: "123",
postal_code: "12345", postal_code: "12345",
city: "Berlin", city: "Berlin",
join_date: ~D[2020-01-15] join_date: ~D[2020-01-15]
}) },
|> Ash.create(actor: system_actor) actor: system_actor
)
{:ok, member2} = {:ok, member2} =
Member Mv.Membership.create_member(
|> Ash.Changeset.for_create(:create_member, %{ %{first_name: "Bob", last_name: "Brown", email: "bob@example.com"},
first_name: "Bob", actor: system_actor
last_name: "Brown", )
email: "bob@example.com"
})
|> Ash.create(actor: system_actor)
%{ %{
member1: member1, member1: member1,
@ -56,7 +52,9 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
}) })
conn = conn_with_oidc_user(conn) 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 =~ "Email"
assert html =~ m.email assert html =~ m.email

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,29 +10,26 @@ defmodule MvWeb.UserLive.FormTest do
end end
describe "new user form - display" do 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") {:ok, view, html} = setup_live_view(conn, "/users/new")
# Basic form elements
assert html =~ "New User" assert html =~ "New User"
assert html =~ "Email" assert html =~ "Email"
assert html =~ "Set Password" assert html =~ "Set Password"
assert has_element?(view, "form#user-form[phx-submit='save']") assert has_element?(view, "form#user-form[phx-submit='save']")
assert has_element?(view, "input[name='user[email]']") assert has_element?(view, "input[name='user[email]']")
assert has_element?(view, "input[type='checkbox'][name='set_password']") 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]']")
refute has_element?(view, "input[name='user[password_confirmation]']") 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() 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]']")
assert has_element?(view, "input[name='user[password_confirmation]']") assert has_element?(view, "input[name='user[password_confirmation]']")
assert render(view) =~ "Password requirements" assert render(view) =~ "Password requirements"
@ -115,7 +112,7 @@ defmodule MvWeb.UserLive.FormTest do
) )
assert user.hashed_password != nil assert user.hashed_password != nil
assert String.starts_with?(user.hashed_password, "$2b$") refute is_nil(user.hashed_password)
end end
end end
@ -153,22 +150,21 @@ defmodule MvWeb.UserLive.FormTest do
end end
describe "edit user form - display" do 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"}) user = create_test_user(%{email: "editme@example.com"})
{:ok, view, html} = setup_live_view(conn, "/users/#{user.id}/edit") {:ok, view, html} = setup_live_view(conn, "/users/#{user.id}/edit")
# Basic form elements
assert html =~ "Edit User" assert html =~ "Edit User"
assert html =~ "Change Password" assert html =~ "Change Password"
assert has_element?(view, "input[name='user[email]'][value='editme@example.com']") 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" 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() 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]']") assert has_element?(view, "input[name='user[password]']")
refute has_element?(view, "input[name='user[password_confirmation]']") refute has_element?(view, "input[name='user[password_confirmation]']")
assert render(view) =~ "Admin Note" assert render(view) =~ "Admin Note"
@ -214,7 +210,8 @@ defmodule MvWeb.UserLive.FormTest do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = Mv.Helpers.SystemActor.get_system_actor()
updated_user = Ash.reload!(user, domain: Mv.Accounts, actor: system_actor) updated_user = Ash.reload!(user, domain: Mv.Accounts, actor: system_actor)
assert updated_user.hashed_password != original_password assert updated_user.hashed_password != original_password
assert String.starts_with?(updated_user.hashed_password, "$2b$") assert not is_nil(updated_user.hashed_password)
assert updated_user.hashed_password != ""
end end
end end
@ -258,39 +255,6 @@ defmodule MvWeb.UserLive.FormTest do
end end
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 describe "member linking - display" do
test "shows linked member with unlink button when user has member", %{conn: conn} do test "shows linked member with unlink button when user has member", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = Mv.Helpers.SystemActor.get_system_actor()
@ -421,6 +385,39 @@ defmodule MvWeb.UserLive.FormTest do
end end
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 describe "system actor user" do
test "redirects to user list when editing system actor user", %{conn: conn} do test "redirects to user list when editing system actor user", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = Mv.Helpers.SystemActor.get_system_actor()

View file

@ -3,46 +3,38 @@ defmodule MvWeb.UserLive.IndexTest do
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
describe "basic functionality" do describe "basic functionality" do
test "shows translated title in German", %{conn: conn} do @tag :ui
conn = conn_with_oidc_user(conn) test "displays users in a table with basic UI elements", %{conn: conn} do
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
# Create test users # 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"}) _user2 = create_test_user(%{email: "bob@example.com", oidc_id: "bob456"})
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/users") {:ok, _view, html} = live(conn, "/users")
# Basic table rendering
assert html =~ "alice@example.com" assert html =~ "alice@example.com"
assert html =~ "bob@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 =~ "Edit"
assert html =~ "Delete" 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
end end
@ -56,6 +48,7 @@ defmodule MvWeb.UserLive.IndexTest do
%{users: [user_a, user_z, user_m]} %{users: [user_a, user_z, user_m]}
end end
@tag :slow
test "initially sorts by email ascending", %{conn: conn} do test "initially sorts by email ascending", %{conn: conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/users") {: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" assert mike_pos < zulu_pos, "mike@example.com should appear before zulu@example.com"
end end
@tag :slow
test "can sort email descending by clicking sort button", %{conn: conn} do test "can sort email descending by clicking sort button", %{conn: conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/users") {: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" "mike@example.com should appear before alpha@example.com when sorted desc"
end end
test "toggles back to ascending when clicking sort button twice", %{conn: conn} do @tag :ui
conn = conn_with_oidc_user(conn) test "toggles sort direction and shows correct icons", %{conn: conn} do
{: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
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/users") {:ok, view, _html} = live(conn, "/users")
@ -129,6 +103,19 @@ defmodule MvWeb.UserLive.IndexTest do
view |> element("button[phx-value-field='email']") |> render_click() view |> element("button[phx-value-field='email']") |> render_click()
html = render(view) html = render(view)
assert html =~ "hero-chevron-down" 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
end end
@ -139,24 +126,23 @@ defmodule MvWeb.UserLive.IndexTest do
%{users: [user1, user2]} %{users: [user1, user2]}
end 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) conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/users") {:ok, _view, html} = live(conn, "/users")
# Check select all checkbox exists
assert html =~ ~s(name="select_all") assert html =~ ~s(name="select_all")
assert html =~ ~s(phx-click="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="#{user1.id}")
assert html =~ ~s(name="#{user2.id}") assert html =~ ~s(name="#{user2.id}")
assert html =~ ~s(phx-click="select_user") assert html =~ ~s(phx-click="select_user")
end 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) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/users") {: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='#{user1.id}']") |> has_element?()
assert view |> element("input[type='checkbox'][name='#{user2.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 refute view
|> element("input[type='checkbox'][name='select_all'][checked]") |> element("input[type='checkbox'][name='select_all'][checked]")
|> has_element?() |> has_element?()
# Select first user checkbox # Select first user checkbox
html = view |> element("input[type='checkbox'][name='#{user1.id}']") |> render_click() 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) # The select_all checkbox should still not be checked (not all users selected)
refute view refute view
|> element("input[type='checkbox'][name='select_all'][checked]") |> element("input[type='checkbox'][name='select_all'][checked]")
|> has_element?() |> has_element?()
# Page should still function normally # Deselect user
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
html = view |> element("input[type='checkbox'][name='#{user1.id}']") |> render_click() 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 refute view
|> element("input[type='checkbox'][name='select_all'][checked]") |> element("input[type='checkbox'][name='select_all'][checked]")
|> has_element?() |> has_element?()
# Page should still function normally
assert html =~ "Email"
assert html =~ to_string(user1.email)
end 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) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/users") {:ok, view, _html} = live(conn, "/users")
@ -227,23 +199,9 @@ defmodule MvWeb.UserLive.IndexTest do
|> element("input[type='checkbox'][name='select_all'][checked]") |> element("input[type='checkbox'][name='select_all'][checked]")
|> has_element?() |> has_element?()
# Page should still function normally and show all users
assert html =~ "Email" assert html =~ "Email"
assert html =~ to_string(user1.email) assert html =~ to_string(user1.email)
assert html =~ to_string(user2.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 # Then deselect all
html = view |> element("input[type='checkbox'][name='select_all']") |> render_click() 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]") |> element("input[type='checkbox'][name='#{user2.id}'][checked]")
|> has_element?() |> has_element?()
# Page should still function normally
assert html =~ "Email" assert html =~ "Email"
assert html =~ to_string(user1.email)
assert html =~ to_string(user2.email)
end end
@tag :slow
test "select all automatically checks when all individual users are selected", %{ test "select all automatically checks when all individual users are selected", %{
conn: conn, conn: conn,
users: [user1, user2] users: [_user1, _user2]
} do } do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/users") {:ok, view, html} = live(conn, "/users")
# Initially nothing should be checked # Get all user IDs from the rendered HTML by finding all checkboxes with phx-click="select_user"
refute view # Extract user IDs from the HTML (they appear as name attributes on checkboxes)
|> element("input[type='checkbox'][name='select_all'][checked]") user_ids =
|> has_element?() 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 # Skip if no users found (shouldn't happen, but be safe)
view |> element("input[type='checkbox'][name='#{user1.id}']") |> render_click() if user_ids != [] do
# Select all should still not be checked (only 1 of 2+ users selected) # Initially nothing should be checked
refute view refute view
|> element("input[type='checkbox'][name='select_all'][checked]") |> element("input[type='checkbox'][name='select_all'][checked]")
|> has_element?() |> has_element?()
# Select second user # Select all users one by one
html = view |> element("input[type='checkbox'][name='#{user2.id}']") |> render_click() 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) # Now select all should be automatically checked (all individual users are selected)
# Note: This test might need adjustment based on actual implementation assert view
# The logic depends on whether authenticated user is included in the count |> element("input[type='checkbox'][name='select_all'][checked]")
assert html =~ "Email" |> has_element?()
assert html =~ to_string(user1.email) end
assert html =~ to_string(user2.email)
end end
end end
@ -307,11 +272,12 @@ defmodule MvWeb.UserLive.IndexTest do
# Confirm user is displayed # Confirm user is displayed
assert render(view) =~ "delete-me@example.com" 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() 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) html = render(view)
refute html =~ "delete-me@example.com"
# Table header should still be there # Table header should still be there
assert html =~ "Email" assert html =~ "Email"
end end
@ -327,52 +293,39 @@ defmodule MvWeb.UserLive.IndexTest do
end end
describe "navigation" do 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"}) user = create_test_user(%{email: "navigate@example.com"})
conn = conn_with_oidc_user(conn) 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 # Check that user row contains link to show page
# The actual navigation would happen via JavaScript
html = render(view)
assert html =~ ~s(/users/#{user.id}) 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) 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) assert html =~ ~s(/users/new)
end end
end end
describe "translations" do 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) conn = conn_with_oidc_user(conn)
# Test German translations
conn = Plug.Test.init_test_session(conn, locale: "de") 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" # Test English translations
assert html =~ "Benutzer*in auswählen"
end
test "shows English translations for selection", %{conn: conn} do
conn = conn_with_oidc_user(conn)
Gettext.put_locale(MvWeb.Gettext, "en") Gettext.put_locale(MvWeb.Gettext, "en")
{:ok, _view, html} = live(conn, "/users") {:ok, _view, html_en} = live(conn, "/users")
# Check that aria-label attributes exist (structure is there)
# Note: English translations might be empty strings by default assert html_en =~ ~s(aria-label=)
# This test would verify the structure is there
# Checking that aria-label attributes exist
assert html =~ ~s(aria-label=)
end end
end end
@ -427,6 +380,7 @@ defmodule MvWeb.UserLive.IndexTest do
end end
describe "member linking display" do describe "member linking display" do
@tag :slow
test "displays linked member name in user list", %{conn: conn} do test "displays linked member name in user list", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = Mv.Helpers.SystemActor.get_system_actor()

View file

@ -1,44 +1,81 @@
defmodule Mv.SeedsTest do 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 use Mv.DataCase, async: false
require Ash.Query 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 setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor() 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} %{actor: system_actor}
end end
describe "Seeds script" do describe "Seeds script" do
test "runs successfully without errors", %{actor: actor} do @tag :slow
# Run the seeds script - should not raise any errors test "runs successfully and creates basic data", %{actor: actor} do
assert Code.eval_file("priv/repo/seeds.exs") # Smoke test: verify seeds created essential data structures
# Basic smoke test: ensure some data was created
{:ok, users} = Ash.read(Mv.Accounts.User, actor: actor) {:ok, users} = Ash.read(Mv.Accounts.User, actor: actor)
{:ok, members} = Ash.read(Mv.Membership.Member, actor: actor) {:ok, members} = Ash.read(Mv.Membership.Member, actor: actor)
{:ok, custom_fields} = Ash.read(Mv.Membership.CustomField, 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?(users), "Seeds should create at least one user"
assert not Enum.empty?(members), "Seeds should create at least one member" assert not Enum.empty?(members), "Seeds should create at least one member"
assert not Enum.empty?(custom_fields), "Seeds should create at least one custom field" assert not Enum.empty?(custom_fields), "Seeds should create at least one custom field"
assert length(roles) >= 5, "Seeds should create at least 5 authorization roles"
end end
test "can be run multiple times (idempotent)", %{actor: actor} do @tag :slow
# Run seeds first time test "is idempotent when run multiple times", %{actor: actor} do
assert Code.eval_file("priv/repo/seeds.exs") # Seeds already run once in setup - count current state
# Count records
{:ok, users_count_1} = Ash.read(Mv.Accounts.User, actor: actor) {:ok, users_count_1} = Ash.read(Mv.Accounts.User, actor: actor)
{:ok, members_count_1} = Ash.read(Mv.Membership.Member, 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") assert Code.eval_file("priv/repo/seeds.exs")
# Count records again - should be the same (upsert, not duplicate) # Count records again - should be the same (upsert, not duplicate)
{:ok, users_count_2} = Ash.read(Mv.Accounts.User, actor: actor) {:ok, users_count_2} = Ash.read(Mv.Accounts.User, actor: actor)
{:ok, members_count_2} = Ash.read(Mv.Membership.Member, 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), assert length(users_count_1) == length(users_count_2),
"Users count should remain same after re-running seeds" "Users count should remain same after re-running seeds"
@ -46,215 +83,45 @@ defmodule Mv.SeedsTest do
assert length(members_count_1) == length(members_count_2), assert length(members_count_1) == length(members_count_2),
"Members count should remain same after re-running seeds" "Members count should remain same after re-running seeds"
assert length(custom_fields_count_1) == length(custom_fields_count_2), assert length(roles_count_1) == length(roles_count_2),
"CustomFields count should remain same after re-running seeds" "Roles 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"
end end
end end
describe "Authorization roles (from seeds)" do describe "Critical bootstrap invariants" do
test "creates all 5 authorization roles with correct permission sets" do test "Admin user has Admin role and can authenticate", %{actor: _actor} do
# Run seeds once for this test # Critical: Without this, initial system setup fails
Code.eval_file("priv/repo/seeds.exs") admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
{:ok, roles} = Ash.read(Mv.Authorization.Role, domain: Mv.Authorization, authorize?: false)
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 assert admin_user != nil, "Admin user must exist after seeds run"
role_configs = [
{"Mitglied", "own_data", true},
{"Vorstand", "read_only", false},
{"Kassenwart", "normal_user", false},
{"Buchhaltung", "read_only", false},
{"Admin", "admin", false}
]
Enum.each(role_configs, fn {name, perm_set, is_system} -> {:ok, admin_user_with_role} =
role = Enum.find(roles, &(&1.name == name)) Ash.load(admin_user, :role, domain: Mv.Accounts, authorize?: false)
assert role, "Role #{name} should exist"
assert role.permission_set_name == perm_set assert admin_user_with_role.role != nil, "Admin user must have a role assigned"
assert role.is_system_role == is_system assert admin_user_with_role.role.name == "Admin", "Admin user must have Admin role"
end)
assert admin_user_with_role.role.permission_set_name == "admin",
"Admin role must have admin permission set"
end end
test "Mitglied role is marked as system role" do test "Mitglied system role exists and is correctly configured" do
Code.eval_file("priv/repo/seeds.exs") # Critical: Default role for new users - system breaks without this
{:ok, mitglied} = {:ok, mitglied} =
Mv.Authorization.Role Mv.Authorization.Role
|> Ash.Query.filter(name == "Mitglied") |> Ash.Query.filter(name == "Mitglied")
|> Ash.read_one(domain: Mv.Authorization, authorize?: false) |> Ash.read_one(domain: Mv.Authorization, authorize?: false)
assert mitglied.is_system_role == true assert mitglied != nil, "Mitglied role must exist"
end assert mitglied.is_system_role == true, "Mitglied role must be marked as system role"
test "all roles have valid permission_set_names" do assert mitglied.permission_set_name == "own_data",
Code.eval_file("priv/repo/seeds.exs") "Mitglied role must have own_data permission set"
{: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"
end end
end 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) Ecto.Adapters.SQL.Sandbox.mode(Mv.Repo, :manual)