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
26 changed files with 1503 additions and 446 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

@ -1661,6 +1661,59 @@ 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

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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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,6 +58,7 @@ 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()
@ -100,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(
@ -112,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}}} =
@ -129,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()
@ -142,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

@ -155,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,
@ -223,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")
@ -231,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

@ -12,6 +12,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
alias Mv.Membership.CustomField 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()
@ -45,6 +46,7 @@ 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()
@ -85,6 +87,7 @@ 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()

View file

@ -52,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

@ -235,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

@ -1787,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

@ -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)