Compare commits
30 commits
d77096c800
...
4473cfd372
| Author | SHA1 | Date | |
|---|---|---|---|
| 4473cfd372 | |||
| 5a2f035ecc | |||
| c9431caabe | |||
| 9a7622ebed | |||
| 1d17c4f2dd | |||
| 36b5d5880b | |||
| ca88a230b9 | |||
| 709cf010c6 | |||
| 9b314a9806 | |||
| b4adf63e83 | |||
| 124ab295a6 | |||
| bb7e3cbe77 | |||
| dddad69e88 | |||
| 0a1b52d978 | |||
| 17974d7a12 | |||
| 1019914d50 | |||
| 0b29fbbd21 | |||
| 25da6a6820 | |||
| 3f0dc868c9 | |||
| c3ad8894b0 | |||
| ea3bdcaa65 | |||
| 050ca4a13c | |||
| eb2b2436be | |||
| 91f8bb03bc | |||
| 15d328afbf | |||
| 6efad280bd | |||
| 858a0fc0d0 | |||
| 67e06e12ce | |||
| fce01ddf83 | |||
| f9403c1da9 |
66 changed files with 2681 additions and 1329 deletions
115
.drone.yml
115
.drone.yml
|
|
@ -1,6 +1,6 @@
|
|||
kind: pipeline
|
||||
type: docker
|
||||
name: check
|
||||
name: check-fast
|
||||
|
||||
services:
|
||||
- name: postgres
|
||||
|
|
@ -72,7 +72,7 @@ steps:
|
|||
echo "Postgres did not become available, aborting."
|
||||
exit 1
|
||||
|
||||
- name: test
|
||||
- name: test-fast
|
||||
image: docker.io/library/elixir:1.18.3-otp-27
|
||||
environment:
|
||||
MIX_ENV: test
|
||||
|
|
@ -83,7 +83,114 @@ steps:
|
|||
- mix local.hex --force
|
||||
# Fetch dependencies
|
||||
- mix deps.get
|
||||
# Run tests
|
||||
# Run fast tests (excludes slow/performance and UI tests)
|
||||
- mix test --exclude slow --exclude ui
|
||||
|
||||
- name: rebuild-cache
|
||||
image: drillster/drone-volume-cache
|
||||
settings:
|
||||
rebuild: true
|
||||
mount:
|
||||
- ./deps
|
||||
- ./_build
|
||||
volumes:
|
||||
- name: cache
|
||||
path: /cache
|
||||
|
||||
volumes:
|
||||
- name: cache
|
||||
host:
|
||||
path: /tmp/drone_cache
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: check-full
|
||||
|
||||
services:
|
||||
- name: postgres
|
||||
image: docker.io/library/postgres:18.1
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- promote
|
||||
target:
|
||||
- production
|
||||
|
||||
steps:
|
||||
- name: compute cache key
|
||||
image: docker.io/library/elixir:1.18.3-otp-27
|
||||
commands:
|
||||
- mix_lock_hash=$(sha256sum mix.lock | cut -d ' ' -f 1)
|
||||
- echo "$DRONE_REPO_OWNER/$DRONE_REPO_NAME/$mix_lock_hash" >> .cache_key
|
||||
# Print cache key for debugging
|
||||
- cat .cache_key
|
||||
|
||||
- name: restore-cache
|
||||
image: drillster/drone-volume-cache
|
||||
settings:
|
||||
restore: true
|
||||
mount:
|
||||
- ./deps
|
||||
- ./_build
|
||||
ttl: 30
|
||||
volumes:
|
||||
- name: cache
|
||||
path: /cache
|
||||
|
||||
- name: lint
|
||||
image: docker.io/library/elixir:1.18.3-otp-27
|
||||
commands:
|
||||
# Install hex package manager
|
||||
- mix local.hex --force
|
||||
# Fetch dependencies
|
||||
- mix deps.get
|
||||
# Check for compilation errors & warnings
|
||||
- mix compile --warnings-as-errors
|
||||
# Check formatting
|
||||
- mix format --check-formatted
|
||||
# Security checks
|
||||
- mix sobelow --config
|
||||
# Check dependencies for known vulnerabilities
|
||||
- mix deps.audit
|
||||
# Check for dependencies that are not maintained anymore
|
||||
- mix hex.audit
|
||||
# Provide hints for improving code quality
|
||||
- mix credo
|
||||
# Check that translations are up to date
|
||||
- mix gettext.extract --check-up-to-date
|
||||
|
||||
- name: wait_for_postgres
|
||||
image: docker.io/library/postgres:18.1
|
||||
commands:
|
||||
# Wait for postgres to become available
|
||||
- |
|
||||
for i in {1..20}; do
|
||||
if pg_isready -h postgres -U postgres; then
|
||||
exit 0
|
||||
else
|
||||
true
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "Postgres did not become available, aborting."
|
||||
exit 1
|
||||
|
||||
- name: test-all
|
||||
image: docker.io/library/elixir:1.18.3-otp-27
|
||||
environment:
|
||||
MIX_ENV: test
|
||||
TEST_POSTGRES_HOST: postgres
|
||||
TEST_POSTGRES_PORT: 5432
|
||||
commands:
|
||||
# Install hex package manager
|
||||
- mix local.hex --force
|
||||
# Fetch dependencies
|
||||
- mix deps.get
|
||||
# Run all tests (including slow/performance and UI tests)
|
||||
- mix test
|
||||
|
||||
- name: rebuild-cache
|
||||
|
|
@ -147,7 +254,7 @@ steps:
|
|||
- push
|
||||
|
||||
depends_on:
|
||||
- check
|
||||
- check-fast
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
|
|
|
|||
48
.forgejo/README.md
Normal file
48
.forgejo/README.md
Normal 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)
|
||||
|
|
@ -683,6 +683,13 @@ end
|
|||
- Falls back to admin user from seeds if system user doesn't exist
|
||||
- Should NEVER be used for user-initiated actions (only systemic operations)
|
||||
|
||||
**DO NOT use system actor as a fallback:**
|
||||
|
||||
- **Never** fall back to `Mv.Helpers.SystemActor.get_system_actor()` when an actor is missing or nil (e.g. in validations, changes, or when reading from context).
|
||||
- Fallbacks hide bugs (callers forget to pass actor) and can cause privilege escalation (unauthenticated or low-privilege paths run with system rights).
|
||||
- If no actor is available, fail explicitly (validation error, Forbidden, or clear error message). Fix the caller to pass the correct actor instead of adding a fallback.
|
||||
- Use system actor only where the operation is **explicitly** a systemic operation (see list above); never as a "safety net" when actor is absent.
|
||||
|
||||
**User Mode vs System Mode:**
|
||||
|
||||
- **User Mode**: User-initiated actions use the actual user actor, policies are enforced
|
||||
|
|
@ -1654,10 +1661,87 @@ end
|
|||
- Mock external services
|
||||
- Use fixtures efficiently
|
||||
|
||||
**Performance Tests:**
|
||||
|
||||
Performance tests that explicitly validate performance characteristics should be tagged with `@tag :slow` or `@describetag :slow` to exclude them from standard test runs. This improves developer feedback loops while maintaining comprehensive coverage.
|
||||
|
||||
**When to Tag as `:slow`:**
|
||||
|
||||
- Tests that explicitly measure execution time or validate performance characteristics
|
||||
- Tests that use large datasets (e.g., 50+ records) to test scalability
|
||||
- Tests that validate query optimization (N+1 prevention, index usage)
|
||||
|
||||
**Tagging Guidelines:**
|
||||
|
||||
- Use `@tag :slow` for individual tests
|
||||
- Use `@describetag :slow` for entire describe blocks (not `@moduletag`, as it affects all tests in the module)
|
||||
- Performance tests should include measurable assertions (query counts, timing with tolerance, etc.)
|
||||
|
||||
**UI Tests:**
|
||||
|
||||
UI tests that validate basic HTML rendering, Phoenix LiveView navigation, or framework functionality (Gettext translations, form elements, UI state changes) should be tagged with `@tag :ui` or `@describetag :ui` to exclude them from fast CI runs. Use `@tag :ui` for individual tests and `@describetag :ui` for describe blocks. UI tests can be consolidated when they test similar elements (e.g., multiple translation tests combined into one). Do not tag business logic tests (e.g., "can delete a user"), validation tests, or data persistence tests as `:ui`.
|
||||
|
||||
**Running Tests:**
|
||||
|
||||
```bash
|
||||
# Fast tests only (excludes slow and UI tests)
|
||||
mix test --exclude slow --exclude ui
|
||||
# Or use the Justfile command:
|
||||
just test-fast
|
||||
|
||||
# UI tests only
|
||||
mix test --only ui
|
||||
# Or use the Justfile command:
|
||||
just ui
|
||||
|
||||
# Performance tests only
|
||||
mix test --only slow
|
||||
# Or use the Justfile command:
|
||||
just slow
|
||||
|
||||
# All tests (including slow and UI tests)
|
||||
mix test
|
||||
# Or use the Justfile command:
|
||||
just test
|
||||
# Or use the Justfile command:
|
||||
just test-all
|
||||
```
|
||||
|
||||
**Test Organization Best Practices:**
|
||||
|
||||
- **Fast Tests (Standard CI):** Business logic, validations, data persistence, edge cases
|
||||
- **UI Tests (Full Test Suite):** Basic HTML rendering, navigation, translations, UI state
|
||||
- **Performance Tests (Full Test Suite):** Query optimization, large datasets, timing assertions
|
||||
|
||||
This organization ensures fast feedback in standard CI while maintaining comprehensive coverage via promotion before merge.
|
||||
---
|
||||
|
||||
## 5. Security Guidelines
|
||||
|
||||
### 5.0 No system-actor fallbacks (mandatory)
|
||||
|
||||
**Do not use the system actor as a fallback when an actor is missing.**
|
||||
|
||||
Examples of forbidden patterns:
|
||||
|
||||
```elixir
|
||||
# ❌ FORBIDDEN - Fallback to system actor when actor is nil
|
||||
actor = Map.get(changeset.context, :actor) || Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
# ❌ FORBIDDEN - "Safety" fallback in validations, changes, or helpers
|
||||
actor = opts[:actor] || Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
# ❌ FORBIDDEN - Default actor in function options
|
||||
def list_something(opts \\ []) do
|
||||
actor = Keyword.get(opts, :actor) || Mv.Helpers.SystemActor.get_system_actor()
|
||||
# ...
|
||||
end
|
||||
```
|
||||
|
||||
**Why:** Fallbacks hide missing-actor bugs and can lead to privilege escalation (e.g. a request without actor would run with system privileges). Always require the caller to pass the actor for user-facing or context-dependent operations; if none is available, return an error or fail validation instead of using the system actor.
|
||||
|
||||
**Allowed:** Use the system actor only where the operation is **by design** a systemic operation (e.g. email sync, seeds, test fixtures, background jobs) and you explicitly call `SystemActor.get_system_actor()` at that call site—never as a fallback when `actor` is nil or absent.
|
||||
|
||||
### 5.1 Authentication & Authorization
|
||||
|
||||
**Use AshAuthentication:**
|
||||
|
|
|
|||
23
Justfile
23
Justfile
|
|
@ -22,7 +22,7 @@ seed-database:
|
|||
start-database:
|
||||
docker compose up -d
|
||||
|
||||
ci-dev: lint audit test
|
||||
ci-dev: lint audit test-fast
|
||||
|
||||
gettext:
|
||||
mix gettext.extract
|
||||
|
|
@ -41,9 +41,30 @@ audit:
|
|||
mix deps.audit
|
||||
mix hex.audit
|
||||
|
||||
# Run all tests
|
||||
test *args: install-dependencies
|
||||
mix test {{args}}
|
||||
|
||||
# Run only fast tests (excludes slow/performance and UI tests)
|
||||
test-fast *args: install-dependencies
|
||||
mix test --exclude slow --exclude ui {{args}}
|
||||
|
||||
# Run only UI tests
|
||||
ui *args: install-dependencies
|
||||
mix test --only ui {{args}}
|
||||
|
||||
# Run only slow/performance tests
|
||||
slow *args: install-dependencies
|
||||
mix test --only slow {{args}}
|
||||
|
||||
# Run only slow/performance tests (alias for consistency)
|
||||
test-slow *args: install-dependencies
|
||||
mix test --only slow {{args}}
|
||||
|
||||
# Run all tests (fast + slow + ui)
|
||||
test-all *args: install-dependencies
|
||||
mix test {{args}}
|
||||
|
||||
format:
|
||||
mix format
|
||||
|
||||
|
|
|
|||
|
|
@ -1101,28 +1101,23 @@ end
|
|||
|
||||
### CustomField Resource Policies
|
||||
|
||||
**Location:** `lib/mv/membership/custom_field.ex`
|
||||
**Location:** `lib/membership/custom_field.ex`
|
||||
|
||||
**No Special Cases:** All users can read, only admin can write.
|
||||
|
||||
```elixir
|
||||
defmodule Mv.Membership.CustomField do
|
||||
use Ash.Resource, ...
|
||||
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
policies do
|
||||
# All authenticated users can read custom fields (needed for forms)
|
||||
# Write operations are admin-only
|
||||
policy action_type([:read, :create, :update, :destroy]) do
|
||||
description "Check permissions from user's role"
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
|
||||
# DEFAULT: Forbid
|
||||
policy action_type([:read, :create, :update, :destroy]) do
|
||||
forbid_if always()
|
||||
end
|
||||
end
|
||||
|
||||
# ...
|
||||
end
|
||||
```
|
||||
|
|
|
|||
877
docs/test-performance-optimization.md
Normal file
877
docs/test-performance-optimization.md
Normal 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.
|
||||
|
|
@ -50,7 +50,8 @@ defmodule Mv.Membership.CustomField do
|
|||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
postgres do
|
||||
table "custom_fields"
|
||||
|
|
@ -79,6 +80,13 @@ defmodule Mv.Membership.CustomField do
|
|||
end
|
||||
end
|
||||
|
||||
policies do
|
||||
policy action_type([:read, :create, :update, :destroy]) do
|
||||
description "Check permissions from user's role"
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
|
||||
|
|
|
|||
|
|
@ -471,11 +471,12 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
end
|
||||
|
||||
# Validate required custom fields
|
||||
validate fn changeset, _ ->
|
||||
# Validate required custom fields (actor from validation context only; no fallback)
|
||||
validate fn changeset, context ->
|
||||
provided_values = provided_custom_field_values(changeset)
|
||||
actor = context.actor
|
||||
|
||||
case Mv.Membership.list_required_custom_fields() do
|
||||
case Mv.Membership.list_required_custom_fields(actor: actor) do
|
||||
{:ok, required_custom_fields} ->
|
||||
missing_fields = missing_required_fields(required_custom_fields, provided_values)
|
||||
|
||||
|
|
@ -485,6 +486,24 @@ defmodule Mv.Membership.Member do
|
|||
build_custom_field_validation_error(missing_fields)
|
||||
end
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
Logger.warning(
|
||||
"Required custom fields validation: actor not authorized to read CustomField"
|
||||
)
|
||||
|
||||
{:error,
|
||||
field: :custom_field_values,
|
||||
message:
|
||||
"You are not authorized to perform this action. Please sign in again or contact support."}
|
||||
|
||||
{:error, :missing_actor} ->
|
||||
Logger.warning("Required custom fields validation: no actor in context")
|
||||
|
||||
{:error,
|
||||
field: :custom_field_values,
|
||||
message:
|
||||
"You are not authorized to perform this action. Please sign in again or contact support."}
|
||||
|
||||
{:error, error} ->
|
||||
Logger.error(
|
||||
"Failed to load custom fields for validation: #{inspect(error)}. Required field validation cannot be performed."
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ defmodule Mv.Membership do
|
|||
The domain exposes these main actions:
|
||||
- Member CRUD: `create_member/1`, `list_members/0`, `update_member/2`, `destroy_member/1`
|
||||
- Custom field value management: `create_custom_field_value/1`, `list_custom_field_values/0`, etc.
|
||||
- Custom field management: `create_custom_field/1`, `list_custom_fields/0`, `list_required_custom_fields/0`, etc.
|
||||
- Custom field management: `create_custom_field/1`, `list_custom_fields/0`, `list_required_custom_fields/1`, etc.
|
||||
- Settings management: `get_settings/0`, `update_settings/2`, `update_member_field_visibility/2`, `update_single_member_field_visibility/3`
|
||||
- Group management: `create_group/1`, `list_groups/0`, `update_group/2`, `destroy_group/1`
|
||||
- Member-group associations: `create_member_group/1`, `list_member_groups/0`, `destroy_member_group/1`
|
||||
|
|
@ -155,25 +155,37 @@ defmodule Mv.Membership do
|
|||
Lists only required custom fields.
|
||||
|
||||
This is an optimized version that filters at the database level instead of
|
||||
loading all custom fields and filtering in memory.
|
||||
loading all custom fields and filtering in memory. Requires an actor for
|
||||
authorization (CustomField read policy). Callers must pass `actor:`; no default.
|
||||
|
||||
## Options
|
||||
|
||||
- `:actor` - Required. The actor for authorization (e.g. current user).
|
||||
All roles can read CustomField; actor must have a valid role.
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, required_custom_fields}` - List of required custom fields
|
||||
- `{:error, error}` - Error reading custom fields
|
||||
- `{:error, :missing_actor}` - When actor is nil (caller must pass actor)
|
||||
- `{:error, error}` - Error reading custom fields (e.g. Forbidden)
|
||||
|
||||
## Examples
|
||||
|
||||
iex> {:ok, required_fields} = Mv.Membership.list_required_custom_fields()
|
||||
iex> {:ok, required_fields} = Mv.Membership.list_required_custom_fields(actor: actor)
|
||||
iex> Enum.all?(required_fields, & &1.required)
|
||||
true
|
||||
|
||||
iex> Mv.Membership.list_required_custom_fields(actor: nil)
|
||||
{:error, :missing_actor}
|
||||
"""
|
||||
def list_required_custom_fields do
|
||||
def list_required_custom_fields(actor: actor) when not is_nil(actor) do
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Query.filter(expr(required == true))
|
||||
|> Ash.read(domain: __MODULE__)
|
||||
|> Ash.read(domain: __MODULE__, actor: actor)
|
||||
end
|
||||
|
||||
def list_required_custom_fields(actor: nil), do: {:error, :missing_actor}
|
||||
|
||||
@doc """
|
||||
Updates the member field visibility configuration.
|
||||
|
||||
|
|
|
|||
|
|
@ -91,7 +91,8 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
|||
|
||||
@impl true
|
||||
def handle_event("save", %{"custom_field" => custom_field_params}, socket) do
|
||||
actor = MvWeb.LiveHelpers.current_actor(socket)
|
||||
# Actor must be passed from parent (IndexComponent); component socket has no current_user
|
||||
actor = socket.assigns[:actor]
|
||||
|
||||
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, custom_field_params, actor) do
|
||||
{:ok, custom_field} ->
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
"""
|
||||
use MvWeb, :live_component
|
||||
|
||||
require Logger
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
assigns = assign(assigns, :field_type_label, &MvWeb.Translations.FieldTypes.label/1)
|
||||
|
|
@ -38,6 +40,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
<.live_component
|
||||
module={MvWeb.CustomFieldLive.FormComponent}
|
||||
id={@form_id}
|
||||
actor={@actor}
|
||||
custom_field={@editing_custom_field}
|
||||
on_save={
|
||||
fn custom_field, action -> send(self(), {:custom_field_saved, custom_field, action}) end
|
||||
|
|
@ -176,6 +179,21 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
"""
|
||||
end
|
||||
|
||||
defp stream_custom_fields(actor, parent_pid) do
|
||||
case Ash.read(Mv.Membership.CustomField, actor: actor) do
|
||||
{:ok, custom_fields} ->
|
||||
custom_fields
|
||||
|
||||
{:error, error} ->
|
||||
Logger.warning(
|
||||
"CustomFieldLive.IndexComponent: failed to load custom fields: #{inspect(error)}"
|
||||
)
|
||||
|
||||
send(parent_pid, {:custom_fields_load_error, error})
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
# Track previous show_form state to detect when form is closed
|
||||
|
|
@ -207,7 +225,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
|> assign_new(:show_delete_modal, fn -> false end)
|
||||
|> assign_new(:custom_field_to_delete, fn -> nil end)
|
||||
|> assign_new(:slug_confirmation, fn -> "" end)
|
||||
|> stream(:custom_fields, Ash.read!(Mv.Membership.CustomField), reset: true)}
|
||||
|> stream(:custom_fields, stream_custom_fields(assigns[:actor], self()), reset: true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -226,7 +244,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
|
||||
@impl true
|
||||
def handle_event("edit_custom_field", %{"id" => id}, socket) do
|
||||
custom_field = Ash.get!(Mv.Membership.CustomField, id)
|
||||
actor = socket.assigns[:actor]
|
||||
custom_field = Ash.get!(Mv.Membership.CustomField, id, actor: actor)
|
||||
|
||||
# Only send event if form was not already open
|
||||
if not socket.assigns[:show_form] do
|
||||
|
|
@ -242,7 +261,13 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
|
||||
@impl true
|
||||
def handle_event("prepare_delete", %{"id" => id}, socket) do
|
||||
custom_field = Ash.get!(Mv.Membership.CustomField, id, load: [:assigned_members_count])
|
||||
actor = socket.assigns[:actor]
|
||||
|
||||
custom_field =
|
||||
Ash.get!(Mv.Membership.CustomField, id,
|
||||
load: [:assigned_members_count],
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|
|
@ -259,9 +284,10 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
@impl true
|
||||
def handle_event("confirm_delete", _params, socket) do
|
||||
custom_field = socket.assigns.custom_field_to_delete
|
||||
actor = socket.assigns[:actor]
|
||||
|
||||
if socket.assigns.slug_confirmation == custom_field.slug do
|
||||
case Ash.destroy(custom_field) do
|
||||
case Ash.destroy(custom_field, actor: actor) do
|
||||
:ok ->
|
||||
send(self(), {:custom_field_deleted, custom_field})
|
||||
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
:if={@active_editing_section != :member_fields}
|
||||
module={MvWeb.CustomFieldLive.IndexComponent}
|
||||
id="custom-fields-component"
|
||||
actor={@current_user}
|
||||
/>
|
||||
</.form_section>
|
||||
|
||||
|
|
@ -503,6 +504,15 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
{:noreply, put_flash(socket, :error, gettext("Slug does not match. Deletion cancelled."))}
|
||||
end
|
||||
|
||||
def handle_info({:custom_fields_load_error, _error}, socket) do
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Could not load data fields. Please check your permissions.")
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:editing_section_changed, section}, socket) do
|
||||
{:noreply, assign(socket, :active_editing_section, section)}
|
||||
|
|
|
|||
|
|
@ -113,16 +113,22 @@ defmodule MvWeb.GroupLive.Index do
|
|||
defp load_groups(actor) do
|
||||
require Ash.Query
|
||||
|
||||
# Load groups without aggregates first (faster)
|
||||
query =
|
||||
Mv.Membership.Group
|
||||
|> Ash.Query.load(:member_count)
|
||||
|> Ash.Query.sort(:name)
|
||||
|
||||
opts = ash_actor_opts(actor)
|
||||
|
||||
case Ash.read(query, opts) do
|
||||
{:ok, groups} ->
|
||||
groups
|
||||
# Load all member counts in a single batch query (avoids N+1)
|
||||
member_counts = load_member_counts_batch(groups)
|
||||
|
||||
# Attach counts to groups
|
||||
Enum.map(groups, fn group ->
|
||||
Map.put(group, :member_count, Map.get(member_counts, group.id, 0))
|
||||
end)
|
||||
|
||||
{:error, _error} ->
|
||||
require Logger
|
||||
|
|
@ -130,4 +136,33 @@ defmodule MvWeb.GroupLive.Index do
|
|||
[]
|
||||
end
|
||||
end
|
||||
|
||||
# Loads all member counts for groups using DB-side aggregation for better performance
|
||||
# This avoids N+1 queries when loading member_count aggregate for each group
|
||||
@spec load_member_counts_batch([Mv.Membership.Group.t()]) :: %{
|
||||
Ecto.UUID.t() => non_neg_integer()
|
||||
}
|
||||
defp load_member_counts_batch(groups) do
|
||||
group_ids = Enum.map(groups, & &1.id)
|
||||
|
||||
if Enum.empty?(group_ids) do
|
||||
%{}
|
||||
else
|
||||
# Use Ecto directly for efficient GROUP BY COUNT query
|
||||
# This is much more performant than loading aggregates for each group individually
|
||||
# Note: We bypass Ash here for performance, but this is a simple read-only query
|
||||
import Ecto.Query
|
||||
|
||||
query =
|
||||
from mg in Mv.Membership.MemberGroup,
|
||||
where: mg.group_id in ^group_ids,
|
||||
group_by: mg.group_id,
|
||||
select: {mg.group_id, count(mg.id)}
|
||||
|
||||
results = Mv.Repo.all(query)
|
||||
|
||||
results
|
||||
|> Enum.into(%{}, fn {group_id, count} -> {group_id, count} end)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -38,7 +38,16 @@ defmodule MvWeb.GroupLive.Show do
|
|||
end
|
||||
|
||||
defp load_group_by_slug(socket, slug, actor) do
|
||||
case Membership.get_group_by_slug(slug, actor: actor, load: [:members, :member_count]) do
|
||||
# Load group with members and member_count
|
||||
# Using explicit load ensures efficient preloading of members relationship
|
||||
require Ash.Query
|
||||
|
||||
query =
|
||||
Mv.Membership.Group
|
||||
|> Ash.Query.filter(slug == ^slug)
|
||||
|> Ash.Query.load([:members, :member_count])
|
||||
|
||||
case Ash.read_one(query, actor: actor, domain: Mv.Membership) do
|
||||
{:ok, nil} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|
|
|
|||
|
|
@ -226,7 +226,7 @@ defmodule MvWeb.MemberLive.Form do
|
|||
def mount(params, _session, socket) do
|
||||
# current_user should be set by on_mount hooks (LiveUserAuth + LiveHelpers)
|
||||
actor = current_actor(socket)
|
||||
{:ok, custom_fields} = Mv.Membership.list_custom_fields()
|
||||
{:ok, custom_fields} = Mv.Membership.list_custom_fields(actor: actor)
|
||||
|
||||
initial_custom_field_values =
|
||||
Enum.map(custom_fields, fn cf ->
|
||||
|
|
|
|||
|
|
@ -328,8 +328,12 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
{new_field, new_order} = determine_new_sort(field, socket)
|
||||
|
||||
old_field = socket.assigns.sort_field
|
||||
|
||||
socket
|
||||
|> update_sort_components(socket.assigns.sort_field, new_field, new_order)
|
||||
|> assign(:sort_field, new_field)
|
||||
|> assign(:sort_order, new_order)
|
||||
|> update_sort_components(old_field, new_field, new_order)
|
||||
|> push_sort_url(new_field, new_order)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -90,11 +90,14 @@ defmodule MvWeb.UserLive.Index do
|
|||
# Selects one user in the list of users
|
||||
@impl true
|
||||
def handle_event("select_user", %{"id" => id}, socket) do
|
||||
# Normalize ID to string for consistent comparison
|
||||
id_str = to_string(id)
|
||||
|
||||
selected =
|
||||
if id in socket.assigns.selected_users do
|
||||
List.delete(socket.assigns.selected_users, id)
|
||||
if id_str in socket.assigns.selected_users do
|
||||
List.delete(socket.assigns.selected_users, id_str)
|
||||
else
|
||||
[id | socket.assigns.selected_users]
|
||||
[id_str | socket.assigns.selected_users]
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, :selected_users, selected)}
|
||||
|
|
@ -129,7 +132,8 @@ defmodule MvWeb.UserLive.Index do
|
|||
def handle_event("select_all", _params, socket) do
|
||||
users = socket.assigns.users
|
||||
|
||||
all_ids = Enum.map(users, & &1.id)
|
||||
# Normalize IDs to strings for consistent comparison
|
||||
all_ids = Enum.map(users, &to_string(&1.id))
|
||||
|
||||
selected =
|
||||
if Enum.sort(socket.assigns.selected_users) == Enum.sort(all_ids) do
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
type="checkbox"
|
||||
name="select_all"
|
||||
phx-click="select_all"
|
||||
checked={Enum.sort(@selected_users) == Enum.map(@users, & &1.id) |> Enum.sort()}
|
||||
checked={Enum.sort(@selected_users) == Enum.map(@users, &to_string(&1.id)) |> Enum.sort()}
|
||||
aria-label={gettext("Select all users")}
|
||||
role="checkbox"
|
||||
/>
|
||||
|
|
@ -26,10 +26,10 @@
|
|||
>
|
||||
<.input
|
||||
type="checkbox"
|
||||
name={user.id}
|
||||
name={to_string(user.id)}
|
||||
phx-click="select_user"
|
||||
phx-value-id={user.id}
|
||||
checked={user.id in @selected_users}
|
||||
phx-value-id={to_string(user.id)}
|
||||
checked={to_string(user.id) in @selected_users}
|
||||
phx-capture-click
|
||||
phx-stop-propagation
|
||||
aria-label={gettext("Select user")}
|
||||
|
|
|
|||
|
|
@ -2262,3 +2262,13 @@ msgstr "Dieser Benutzer kann nicht bearbeitet werden."
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "This user cannot be viewed."
|
||||
msgstr "Dieser Benutzer kann nicht angezeigt werden."
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Not authorized."
|
||||
msgstr "Nicht berechtigt."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Could not load data fields. Please check your permissions."
|
||||
msgstr "Datenfelder konnten nicht geladen werden. Bitte überprüfen Sie Ihre Berechtigungen."
|
||||
|
|
|
|||
|
|
@ -2268,3 +2268,8 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "Not authorized."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Could not load data fields. Please check your permissions."
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -2263,3 +2263,13 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "This user cannot be viewed."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Not authorized."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Could not load data fields. Please check your permissions."
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -118,10 +118,12 @@ for attrs <- [
|
|||
required: false
|
||||
}
|
||||
] do
|
||||
# Bootstrap: no admin user yet; CustomField create requires admin, so skip authorization
|
||||
Membership.create_custom_field!(
|
||||
attrs,
|
||||
upsert?: true,
|
||||
upsert_identity: :unique_name
|
||||
upsert_identity: :unique_name,
|
||||
authorize?: false
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -379,12 +381,16 @@ Enum.each(member_attrs_list, fn member_attrs ->
|
|||
final_member =
|
||||
if is_nil(member.membership_fee_type_id) and
|
||||
Map.has_key?(member_attrs_without_status, :membership_fee_type_id) do
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: member_attrs_without_status.membership_fee_type_id
|
||||
})
|
||||
|> Ash.Changeset.put_context(:actor, admin_user_with_role)
|
||||
|> Ash.update!(actor: admin_user_with_role)
|
||||
{:ok, updated} =
|
||||
Membership.update_member(
|
||||
member,
|
||||
%{
|
||||
membership_fee_type_id: member_attrs_without_status.membership_fee_type_id
|
||||
},
|
||||
actor: admin_user_with_role
|
||||
)
|
||||
|
||||
updated
|
||||
else
|
||||
member
|
||||
end
|
||||
|
|
@ -544,9 +550,12 @@ Enum.with_index(linked_members)
|
|||
fee_type_index = rem(3 + index, length(all_fee_types))
|
||||
fee_type = Enum.at(all_fee_types, fee_type_index)
|
||||
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!(actor: admin_user_with_role)
|
||||
{:ok, updated} =
|
||||
Membership.update_member(member, %{membership_fee_type_id: fee_type.id},
|
||||
actor: admin_user_with_role
|
||||
)
|
||||
|
||||
updated
|
||||
else
|
||||
member
|
||||
end
|
||||
|
|
@ -594,7 +603,7 @@ end)
|
|||
|
||||
# Create sample custom field values for some members
|
||||
all_members = Ash.read!(Membership.Member, actor: admin_user_with_role)
|
||||
all_custom_fields = Ash.read!(Membership.CustomField)
|
||||
all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_role)
|
||||
|
||||
# Helper function to find custom field by name
|
||||
find_field = fn name -> Enum.find(all_custom_fields, &(&1.name == name)) end
|
||||
|
|
|
|||
|
|
@ -239,13 +239,14 @@ defmodule Mv.Membership.CustomFieldDeletionTest do
|
|||
|
||||
# Helper functions
|
||||
defp create_member(actor) do
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "User#{System.unique_integer([:positive])}",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "User#{System.unique_integer([:positive])}",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com"
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
end
|
||||
|
||||
defp create_custom_field(name, value_type, actor) do
|
||||
|
|
|
|||
|
|
@ -17,13 +17,14 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
|
|||
|
||||
# Create a test member
|
||||
{:ok, member} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test.validation@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test.validation@example.com"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
# Create custom fields for different types
|
||||
{:ok, string_field} =
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ defmodule Mv.Membership.GroupIntegrationTest do
|
|||
end
|
||||
end
|
||||
|
||||
@moduletag :slow
|
||||
describe "Query Performance" do
|
||||
test "preloading groups with members avoids N+1 queries", %{actor: actor} do
|
||||
# Create test data
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ defmodule Mv.Membership.MemberAvailableForLinkingTest do
|
|||
end)
|
||||
end
|
||||
|
||||
@tag :slow
|
||||
test "limits results to 10 members even when more exist" do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
|
|
|
|||
|
|
@ -38,10 +38,8 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
|
|||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||
|> Ash.create!(actor: actor)
|
||||
{:ok, member} = Mv.Membership.create_member(attrs, actor: actor)
|
||||
member
|
||||
end
|
||||
|
||||
# Helper to create a cycle
|
||||
|
|
|
|||
|
|
@ -14,31 +14,22 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
|||
|
||||
# Create test members
|
||||
{:ok, member1} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Alice",
|
||||
last_name: "Anderson",
|
||||
email: "alice@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, member2} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Bob",
|
||||
last_name: "Brown",
|
||||
email: "bob@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Bob", last_name: "Brown", email: "bob@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, member3} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Charlie",
|
||||
last_name: "Clark",
|
||||
email: "charlie@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Charlie", last_name: "Clark", email: "charlie@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
# Create custom fields for different types
|
||||
{:ok, string_field} =
|
||||
|
|
@ -112,9 +103,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
|||
|
||||
# Force search_vector update by reloading member
|
||||
{:ok, _updated_member} =
|
||||
member1
|
||||
|> Ash.Changeset.for_update(:update_member, %{})
|
||||
|> Ash.update(actor: system_actor)
|
||||
Mv.Membership.update_member(member1, %{}, actor: system_actor)
|
||||
|
||||
# Search for the custom field value
|
||||
results =
|
||||
|
|
@ -143,9 +132,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
|||
|
||||
# Force search_vector update
|
||||
{:ok, _updated_member} =
|
||||
member1
|
||||
|> Ash.Changeset.for_update(:update_member, %{})
|
||||
|> Ash.update(actor: system_actor)
|
||||
Mv.Membership.update_member(member1, %{}, actor: system_actor)
|
||||
|
||||
# Search for the custom field value
|
||||
results =
|
||||
|
|
@ -174,9 +161,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
|||
|
||||
# Force search_vector update
|
||||
{:ok, _updated_member} =
|
||||
member1
|
||||
|> Ash.Changeset.for_update(:update_member, %{})
|
||||
|> Ash.update(actor: system_actor)
|
||||
Mv.Membership.update_member(member1, %{}, actor: system_actor)
|
||||
|
||||
# Search for partial custom field value (should work via FTS or custom field filter)
|
||||
results =
|
||||
|
|
@ -225,9 +210,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
|||
|
||||
# Force search_vector update
|
||||
{:ok, _updated_member} =
|
||||
member1
|
||||
|> Ash.Changeset.for_update(:update_member, %{})
|
||||
|> Ash.update(actor: system_actor)
|
||||
Mv.Membership.update_member(member1, %{}, actor: system_actor)
|
||||
|
||||
# Search for the custom field value (date is stored as text in search_vector)
|
||||
results =
|
||||
|
|
@ -256,9 +239,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
|||
|
||||
# Force search_vector update
|
||||
{:ok, _updated_member} =
|
||||
member1
|
||||
|> Ash.Changeset.for_update(:update_member, %{})
|
||||
|> Ash.update(actor: system_actor)
|
||||
Mv.Membership.update_member(member1, %{}, actor: system_actor)
|
||||
|
||||
# Search for the custom field value (boolean is stored as "true" or "false" text)
|
||||
results =
|
||||
|
|
@ -287,9 +268,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
|||
|
||||
# Force search_vector update
|
||||
{:ok, _updated_member} =
|
||||
member1
|
||||
|> Ash.Changeset.for_update(:update_member, %{})
|
||||
|> Ash.update(actor: system_actor)
|
||||
Mv.Membership.update_member(member1, %{}, actor: system_actor)
|
||||
|
||||
# Update custom field value
|
||||
{:ok, _updated_cfv} =
|
||||
|
|
@ -334,9 +313,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
|||
|
||||
# Force search_vector update
|
||||
{:ok, _updated_member} =
|
||||
member1
|
||||
|> Ash.Changeset.for_update(:update_member, %{})
|
||||
|> Ash.update(actor: system_actor)
|
||||
Mv.Membership.update_member(member1, %{}, actor: system_actor)
|
||||
|
||||
# Verify it's searchable
|
||||
results =
|
||||
|
|
@ -401,9 +378,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
|||
|
||||
# Update member (should trigger search_vector update including custom fields)
|
||||
{:ok, _updated_member} =
|
||||
member1
|
||||
|> Ash.Changeset.for_update(:update_member, %{notes: "Updated notes"})
|
||||
|> Ash.update(actor: system_actor)
|
||||
Mv.Membership.update_member(member1, %{notes: "Updated notes"}, actor: system_actor)
|
||||
|
||||
# Search should find the custom field value
|
||||
results =
|
||||
|
|
@ -452,9 +427,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
|||
|
||||
# Force search_vector update
|
||||
{:ok, _updated_member} =
|
||||
member1
|
||||
|> Ash.Changeset.for_update(:update_member, %{})
|
||||
|> Ash.update(actor: system_actor)
|
||||
Mv.Membership.update_member(member1, %{}, actor: system_actor)
|
||||
|
||||
# All values should be searchable
|
||||
results1 =
|
||||
|
|
@ -496,9 +469,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
|||
|
||||
# Force search_vector update
|
||||
{:ok, _updated_member} =
|
||||
member1
|
||||
|> Ash.Changeset.for_update(:update_member, %{})
|
||||
|> Ash.update(actor: system_actor)
|
||||
Mv.Membership.update_member(member1, %{}, actor: system_actor)
|
||||
|
||||
# Search for full value (should work via search_vector)
|
||||
results_full =
|
||||
|
|
@ -541,9 +512,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
|||
|
||||
# Force search_vector update
|
||||
{:ok, _updated_member} =
|
||||
member1
|
||||
|> Ash.Changeset.for_update(:update_member, %{})
|
||||
|> Ash.update(actor: system_actor)
|
||||
Mv.Membership.update_member(member1, %{}, actor: system_actor)
|
||||
|
||||
# Search for full phone number (should work via search_vector)
|
||||
results_full =
|
||||
|
|
|
|||
|
|
@ -41,10 +41,8 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
|||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||
|> Ash.create!(actor: actor)
|
||||
{:ok, member} = Mv.Membership.create_member(attrs, actor: actor)
|
||||
member
|
||||
end
|
||||
|
||||
# Helper to create a cycle
|
||||
|
|
@ -76,10 +74,14 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
|||
# Manually assign fee type (this will trigger cycle generation)
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: yearly_type1.id
|
||||
})
|
||||
|> Ash.update!(actor: actor)
|
||||
|> then(fn m ->
|
||||
{:ok, updated} =
|
||||
Mv.Membership.update_member(m, %{membership_fee_type_id: yearly_type1.id},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
updated
|
||||
end)
|
||||
|
||||
# Cycle generation runs synchronously in the same transaction
|
||||
# No need to wait for async completion
|
||||
|
|
@ -140,11 +142,9 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
|||
|
||||
# Change membership fee type (same interval, different amount)
|
||||
assert {:ok, _updated_member} =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: yearly_type2.id
|
||||
})
|
||||
|> Ash.update(actor: actor)
|
||||
Mv.Membership.update_member(member, %{membership_fee_type_id: yearly_type2.id},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Cycle regeneration runs synchronously in the same transaction
|
||||
# No need to wait for async completion
|
||||
|
|
@ -194,10 +194,14 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
|||
# Manually assign fee type (this will trigger cycle generation)
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: yearly_type1.id
|
||||
})
|
||||
|> Ash.update!(actor: actor)
|
||||
|> then(fn m ->
|
||||
{:ok, updated} =
|
||||
Mv.Membership.update_member(m, %{membership_fee_type_id: yearly_type1.id},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
updated
|
||||
end)
|
||||
|
||||
# Cycle generation runs synchronously in the same transaction
|
||||
# No need to wait for async completion
|
||||
|
|
@ -215,11 +219,9 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
|||
|
||||
# Change membership fee type
|
||||
assert {:ok, _updated_member} =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: yearly_type2.id
|
||||
})
|
||||
|> Ash.update(actor: actor)
|
||||
Mv.Membership.update_member(member, %{membership_fee_type_id: yearly_type2.id},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Cycle regeneration runs synchronously in the same transaction
|
||||
# No need to wait for async completion
|
||||
|
|
@ -242,10 +244,14 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
|||
# Manually assign fee type (this will trigger cycle generation)
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: yearly_type1.id
|
||||
})
|
||||
|> Ash.update!(actor: actor)
|
||||
|> then(fn m ->
|
||||
{:ok, updated} =
|
||||
Mv.Membership.update_member(m, %{membership_fee_type_id: yearly_type1.id},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
updated
|
||||
end)
|
||||
|
||||
# Cycle generation runs synchronously in the same transaction
|
||||
# No need to wait for async completion
|
||||
|
|
@ -263,11 +269,9 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
|||
|
||||
# Change membership fee type
|
||||
assert {:ok, _updated_member} =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: yearly_type2.id
|
||||
})
|
||||
|> Ash.update(actor: actor)
|
||||
Mv.Membership.update_member(member, %{membership_fee_type_id: yearly_type2.id},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Cycle regeneration runs synchronously in the same transaction
|
||||
# No need to wait for async completion
|
||||
|
|
@ -290,10 +294,14 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
|||
# Manually assign fee type (this will trigger cycle generation)
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: yearly_type1.id
|
||||
})
|
||||
|> Ash.update!(actor: actor)
|
||||
|> then(fn m ->
|
||||
{:ok, updated} =
|
||||
Mv.Membership.update_member(m, %{membership_fee_type_id: yearly_type1.id},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
updated
|
||||
end)
|
||||
|
||||
# Cycle generation runs synchronously in the same transaction
|
||||
# No need to wait for async completion
|
||||
|
|
@ -357,11 +365,9 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
|||
|
||||
# Change membership fee type
|
||||
assert {:ok, _updated_member} =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: yearly_type2.id
|
||||
})
|
||||
|> Ash.update(actor: actor)
|
||||
Mv.Membership.update_member(member, %{membership_fee_type_id: yearly_type2.id},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Cycle regeneration runs synchronously in the same transaction
|
||||
# No need to wait for async completion
|
||||
|
|
@ -406,10 +412,14 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
|||
# Manually assign fee type (this will trigger cycle generation)
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: yearly_type1.id
|
||||
})
|
||||
|> Ash.update!(actor: actor)
|
||||
|> then(fn m ->
|
||||
{:ok, updated} =
|
||||
Mv.Membership.update_member(m, %{membership_fee_type_id: yearly_type1.id},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
updated
|
||||
end)
|
||||
|
||||
# Cycle generation runs synchronously in the same transaction
|
||||
# No need to wait for async completion
|
||||
|
|
@ -463,11 +473,9 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
|||
|
||||
# Change membership fee type
|
||||
assert {:ok, updated_member} =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: yearly_type2.id
|
||||
})
|
||||
|> Ash.update(actor: actor)
|
||||
Mv.Membership.update_member(member, %{membership_fee_type_id: yearly_type2.id},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Cycle regeneration runs synchronously in the same transaction
|
||||
# No need to wait for async completion
|
||||
|
|
|
|||
|
|
@ -146,16 +146,17 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
|
|||
|> Ash.create!(actor: actor)
|
||||
|
||||
# Create member with join_date and fee type but no explicit start date
|
||||
member =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2024-03-15],
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|> Ash.create!(actor: actor)
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2024-03-15],
|
||||
membership_fee_type_id: fee_type.id
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Should have auto-calculated start date (2024-01-01 for yearly with include_joining_cycle=true)
|
||||
assert member.membership_fee_start_date == ~D[2024-01-01]
|
||||
|
|
@ -177,17 +178,18 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
|
|||
# Create member with explicit start date
|
||||
manual_start_date = ~D[2024-07-01]
|
||||
|
||||
member =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2024-03-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: manual_start_date
|
||||
})
|
||||
|> Ash.create!(actor: actor)
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2024-03-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: manual_start_date
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Should keep the manually set date
|
||||
assert member.membership_fee_start_date == manual_start_date
|
||||
|
|
@ -207,16 +209,17 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
|
|||
|> Ash.create!(actor: actor)
|
||||
|
||||
# Create member
|
||||
member =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2024-03-15],
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|> Ash.create!(actor: actor)
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2024-03-15],
|
||||
membership_fee_type_id: fee_type.id
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Should have next cycle start date (2025-01-01 for yearly with include_joining_cycle=false)
|
||||
assert member.membership_fee_start_date == ~D[2025-01-01]
|
||||
|
|
@ -236,16 +239,17 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
|
|||
|> Ash.create!(actor: actor)
|
||||
|
||||
# Create member without join_date
|
||||
member =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
membership_fee_type_id: fee_type.id
|
||||
# No join_date
|
||||
})
|
||||
|> Ash.create!(actor: actor)
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
membership_fee_type_id: fee_type.id
|
||||
# No join_date
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Should not have auto-calculated start date
|
||||
assert is_nil(member.membership_fee_start_date)
|
||||
|
|
@ -255,16 +259,17 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
|
|||
setup_settings(true, actor)
|
||||
|
||||
# Create member without fee type
|
||||
member =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2024-03-15]
|
||||
# No membership_fee_type_id
|
||||
})
|
||||
|> Ash.create!(actor: actor)
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2024-03-15]
|
||||
# No membership_fee_type_id
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Should not have auto-calculated start date
|
||||
assert is_nil(member.membership_fee_start_date)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
|||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.MembershipFees.Changes.ValidateSameInterval
|
||||
|
||||
|
|
@ -37,10 +36,8 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
|||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||
|> Ash.create!(actor: actor)
|
||||
{:ok, member} = Mv.Membership.create_member(attrs, actor: actor)
|
||||
member
|
||||
end
|
||||
|
||||
describe "validate_interval_match/1" do
|
||||
|
|
@ -52,9 +49,9 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
|||
|
||||
changeset =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: yearly_type2.id
|
||||
})
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: yearly_type2.id},
|
||||
actor: actor
|
||||
)
|
||||
|> ValidateSameInterval.change(%{}, %{})
|
||||
|
||||
assert changeset.valid?
|
||||
|
|
@ -68,9 +65,9 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
|||
|
||||
changeset =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: monthly_type.id
|
||||
})
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: monthly_type.id},
|
||||
actor: actor
|
||||
)
|
||||
|> ValidateSameInterval.change(%{}, %{})
|
||||
|
||||
refute changeset.valid?
|
||||
|
|
@ -90,9 +87,9 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
|||
|
||||
changeset =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: yearly_type.id
|
||||
})
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: yearly_type.id},
|
||||
actor: actor
|
||||
)
|
||||
|> ValidateSameInterval.change(%{}, %{})
|
||||
|
||||
assert changeset.valid?
|
||||
|
|
@ -104,9 +101,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
|||
|
||||
changeset =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: nil
|
||||
})
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: nil}, actor: actor)
|
||||
|> ValidateSameInterval.change(%{}, %{})
|
||||
|
||||
refute changeset.valid?
|
||||
|
|
@ -124,9 +119,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
|||
|
||||
changeset =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
first_name: "New Name"
|
||||
})
|
||||
|> Ash.Changeset.for_update(:update_member, %{first_name: "New Name"}, actor: actor)
|
||||
|> ValidateSameInterval.change(%{}, %{})
|
||||
|
||||
assert changeset.valid?
|
||||
|
|
@ -140,9 +133,9 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
|||
|
||||
changeset =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: quarterly_type.id
|
||||
})
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: quarterly_type.id},
|
||||
actor: actor
|
||||
)
|
||||
|> ValidateSameInterval.change(%{}, %{})
|
||||
|
||||
error = Enum.find(changeset.errors, &(&1.field == :membership_fee_type_id))
|
||||
|
|
@ -179,9 +172,9 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
|||
|
||||
changeset =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: type2.id
|
||||
})
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: type2.id},
|
||||
actor: actor
|
||||
)
|
||||
|> ValidateSameInterval.change(%{}, %{})
|
||||
|
||||
refute changeset.valid?,
|
||||
|
|
@ -199,11 +192,9 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
|||
|
||||
# Try to update member with different interval type
|
||||
assert {:error, %Ash.Error.Invalid{} = error} =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: monthly_type.id
|
||||
})
|
||||
|> Ash.update(actor: actor)
|
||||
Mv.Membership.update_member(member, %{membership_fee_type_id: monthly_type.id},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Check that error is about interval mismatch
|
||||
error_message = extract_error_message(error)
|
||||
|
|
@ -220,11 +211,9 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
|||
|
||||
# Update member with same-interval type
|
||||
assert {:ok, updated_member} =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: yearly_type2.id
|
||||
})
|
||||
|> Ash.update(actor: actor)
|
||||
Mv.Membership.update_member(member, %{membership_fee_type_id: yearly_type2.id},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
assert updated_member.membership_fee_type_id == yearly_type2.id
|
||||
end
|
||||
|
|
|
|||
|
|
@ -52,16 +52,17 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
|
|||
setup_settings(true, actor)
|
||||
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||
|
||||
member =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2023-03-15],
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|> Ash.create!(actor: actor)
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2023-03-15],
|
||||
membership_fee_type_id: fee_type.id
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
cycles = get_member_cycles(member.id, actor)
|
||||
|
||||
|
|
@ -80,16 +81,16 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
|
|||
test "does not create cycles when member has no fee type", %{actor: actor} do
|
||||
setup_settings(true, actor)
|
||||
|
||||
member =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2023-03-15]
|
||||
# No membership_fee_type_id
|
||||
})
|
||||
|> Ash.create!(actor: actor)
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2023-03-15]
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
cycles = get_member_cycles(member.id, actor)
|
||||
|
||||
|
|
@ -100,16 +101,16 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
|
|||
setup_settings(true, actor)
|
||||
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||
|
||||
member =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
membership_fee_type_id: fee_type.id
|
||||
# No join_date
|
||||
})
|
||||
|> Ash.create!(actor: actor)
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
membership_fee_type_id: fee_type.id
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
cycles = get_member_cycles(member.id, actor)
|
||||
|
||||
|
|
@ -123,23 +124,23 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
|
|||
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||
|
||||
# Create member without fee type
|
||||
member =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2023-03-15]
|
||||
})
|
||||
|> Ash.create!(actor: actor)
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2023-03-15]
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Verify no cycles yet
|
||||
assert get_member_cycles(member.id, actor) == []
|
||||
|
||||
# Update to assign fee type
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!(actor: actor)
|
||||
{:ok, member} =
|
||||
Mv.Membership.update_member(member, %{membership_fee_type_id: fee_type.id}, actor: actor)
|
||||
|
||||
cycles = get_member_cycles(member.id, actor)
|
||||
|
||||
|
|
@ -157,15 +158,19 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
|
|||
tasks =
|
||||
Enum.map(1..5, fn i ->
|
||||
Task.async(fn ->
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test#{i}",
|
||||
last_name: "User#{i}",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2023-03-15],
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|> Ash.create!(actor: actor)
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test#{i}",
|
||||
last_name: "User#{i}",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2023-03-15],
|
||||
membership_fee_type_id: fee_type.id
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
member
|
||||
end)
|
||||
end)
|
||||
|
||||
|
|
@ -184,16 +189,17 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
|
|||
setup_settings(true, actor)
|
||||
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||
|
||||
member =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2023-03-15],
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|> Ash.create!(actor: actor)
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2023-03-15],
|
||||
membership_fee_type_id: fee_type.id
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
initial_cycles = get_member_cycles(member.id, actor)
|
||||
initial_count = length(initial_cycles)
|
||||
|
|
|
|||
|
|
@ -37,10 +37,8 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
|
|||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||
|> Ash.create!(actor: actor)
|
||||
{:ok, member} = Mv.Membership.create_member(attrs, actor: actor)
|
||||
member
|
||||
end
|
||||
|
||||
# Helper to create a cycle
|
||||
|
|
|
|||
188
test/mv/membership/custom_field_policies_test.exs
Normal file
188
test/mv/membership/custom_field_policies_test.exs
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
defmodule Mv.Membership.CustomFieldPoliciesTest do
|
||||
@moduledoc """
|
||||
Tests for CustomField resource authorization policies.
|
||||
|
||||
Verifies that all authenticated users with a valid role can read custom fields,
|
||||
and only admin can create/update/destroy custom fields.
|
||||
"""
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
alias Mv.Membership.CustomField
|
||||
alias Mv.Accounts
|
||||
alias Mv.Authorization
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
%{actor: system_actor}
|
||||
end
|
||||
|
||||
defp create_role_with_permission_set(permission_set_name, actor) do
|
||||
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
|
||||
|
||||
case Authorization.create_role(
|
||||
%{
|
||||
name: role_name,
|
||||
description: "Test role for #{permission_set_name}",
|
||||
permission_set_name: permission_set_name
|
||||
},
|
||||
actor: actor
|
||||
) do
|
||||
{:ok, role} -> role
|
||||
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
|
||||
end
|
||||
end
|
||||
|
||||
defp create_user_with_permission_set(permission_set_name, actor) do
|
||||
role = create_role_with_permission_set(permission_set_name, actor)
|
||||
|
||||
{:ok, user} =
|
||||
Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||
email: "user#{System.unique_integer([:positive])}@example.com",
|
||||
password: "testpassword123"
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
{:ok, user} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|
||||
|> Ash.update(actor: actor)
|
||||
|
||||
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
|
||||
user_with_role
|
||||
end
|
||||
|
||||
defp create_custom_field(actor) do
|
||||
{:ok, field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "test_field_#{System.unique_integer([:positive])}",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor, domain: Mv.Membership)
|
||||
|
||||
field
|
||||
end
|
||||
|
||||
describe "read access (all roles)" do
|
||||
test "user with own_data can read all custom fields", %{actor: actor} do
|
||||
custom_field = create_custom_field(actor)
|
||||
user = create_user_with_permission_set("own_data", actor)
|
||||
|
||||
{:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership)
|
||||
ids = Enum.map(fields, & &1.id)
|
||||
assert custom_field.id in ids
|
||||
|
||||
{:ok, fetched} = Ash.get(CustomField, custom_field.id, actor: user, domain: Mv.Membership)
|
||||
assert fetched.id == custom_field.id
|
||||
end
|
||||
|
||||
test "user with read_only can read all custom fields", %{actor: actor} do
|
||||
custom_field = create_custom_field(actor)
|
||||
user = create_user_with_permission_set("read_only", actor)
|
||||
|
||||
{:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership)
|
||||
ids = Enum.map(fields, & &1.id)
|
||||
assert custom_field.id in ids
|
||||
|
||||
{:ok, fetched} = Ash.get(CustomField, custom_field.id, actor: user, domain: Mv.Membership)
|
||||
assert fetched.id == custom_field.id
|
||||
end
|
||||
|
||||
test "user with normal_user can read all custom fields", %{actor: actor} do
|
||||
custom_field = create_custom_field(actor)
|
||||
user = create_user_with_permission_set("normal_user", actor)
|
||||
|
||||
{:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership)
|
||||
ids = Enum.map(fields, & &1.id)
|
||||
assert custom_field.id in ids
|
||||
|
||||
{:ok, fetched} = Ash.get(CustomField, custom_field.id, actor: user, domain: Mv.Membership)
|
||||
assert fetched.id == custom_field.id
|
||||
end
|
||||
|
||||
test "user with admin can read all custom fields", %{actor: actor} do
|
||||
custom_field = create_custom_field(actor)
|
||||
user = create_user_with_permission_set("admin", actor)
|
||||
|
||||
{:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership)
|
||||
ids = Enum.map(fields, & &1.id)
|
||||
assert custom_field.id in ids
|
||||
|
||||
{:ok, fetched} = Ash.get(CustomField, custom_field.id, actor: user, domain: Mv.Membership)
|
||||
assert fetched.id == custom_field.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "write access - non-admin cannot create/update/destroy" do
|
||||
setup %{actor: actor} do
|
||||
user = create_user_with_permission_set("normal_user", actor)
|
||||
custom_field = create_custom_field(actor)
|
||||
%{user: user, custom_field: custom_field}
|
||||
end
|
||||
|
||||
test "non-admin cannot create custom field (forbidden)", %{user: user} do
|
||||
assert {:error, %Ash.Error.Forbidden{}} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "forbidden_field_#{System.unique_integer([:positive])}",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: user, domain: Mv.Membership)
|
||||
end
|
||||
|
||||
test "non-admin cannot update custom field (forbidden)", %{
|
||||
user: user,
|
||||
custom_field: custom_field
|
||||
} do
|
||||
assert {:error, %Ash.Error.Forbidden{}} =
|
||||
custom_field
|
||||
|> Ash.Changeset.for_update(:update, %{description: "Updated"})
|
||||
|> Ash.update(actor: user, domain: Mv.Membership)
|
||||
end
|
||||
|
||||
test "non-admin cannot destroy custom field (forbidden)", %{
|
||||
user: user,
|
||||
custom_field: custom_field
|
||||
} do
|
||||
assert {:error, %Ash.Error.Forbidden{}} =
|
||||
Ash.destroy(custom_field, actor: user, domain: Mv.Membership)
|
||||
end
|
||||
end
|
||||
|
||||
describe "write access - admin can create/update/destroy" do
|
||||
setup %{actor: actor} do
|
||||
user = create_user_with_permission_set("admin", actor)
|
||||
custom_field = create_custom_field(actor)
|
||||
%{user: user, custom_field: custom_field}
|
||||
end
|
||||
|
||||
test "admin can create custom field", %{user: user} do
|
||||
name = "admin_field_#{System.unique_integer([:positive])}"
|
||||
|
||||
assert {:ok, %CustomField{} = field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{name: name, value_type: :string})
|
||||
|> Ash.create(actor: user, domain: Mv.Membership)
|
||||
|
||||
assert field.name == name
|
||||
end
|
||||
|
||||
test "admin can update custom field", %{user: user, custom_field: custom_field} do
|
||||
assert {:ok, updated} =
|
||||
custom_field
|
||||
|> Ash.Changeset.for_update(:update, %{description: "Admin updated"})
|
||||
|> Ash.update(actor: user, domain: Mv.Membership)
|
||||
|
||||
assert updated.description == "Admin updated"
|
||||
end
|
||||
|
||||
test "admin can destroy custom field", %{user: user, custom_field: custom_field} do
|
||||
assert :ok = Ash.destroy(custom_field, actor: user, domain: Mv.Membership)
|
||||
|
||||
assert {:error, _} =
|
||||
Ash.get(CustomField, custom_field.id, domain: Mv.Membership, actor: user)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -9,7 +9,7 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
|
|||
# async: false because we need database commits to be visible across queries
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
||||
alias Mv.Membership.{CustomField, CustomFieldValue}
|
||||
alias Mv.Accounts
|
||||
alias Mv.Authorization
|
||||
|
||||
|
|
@ -62,13 +62,14 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
|
|||
|
||||
defp create_linked_member_for_user(user, actor) do
|
||||
{:ok, member} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Linked",
|
||||
last_name: "Member",
|
||||
email: "linked#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create(actor: actor, return_notifications?: false)
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Linked",
|
||||
last_name: "Member",
|
||||
email: "linked#{System.unique_integer([:positive])}@example.com"
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|
|
@ -80,13 +81,14 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
|
|||
|
||||
defp create_unlinked_member(actor) do
|
||||
{:ok, member} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Unlinked",
|
||||
last_name: "Member",
|
||||
email: "unlinked#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Unlinked",
|
||||
last_name: "Member",
|
||||
email: "unlinked#{System.unique_integer([:positive])}@example.com"
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
member
|
||||
end
|
||||
|
|
|
|||
|
|
@ -78,13 +78,14 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
# NOTE: We need to ensure the member is actually persisted to the database
|
||||
# before we try to link it. Ash may delay writes, so we explicitly return the struct.
|
||||
{:ok, member} =
|
||||
Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Linked",
|
||||
last_name: "Member",
|
||||
email: "linked#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create(actor: admin, return_notifications?: false)
|
||||
Membership.create_member(
|
||||
%{
|
||||
first_name: "Linked",
|
||||
last_name: "Member",
|
||||
email: "linked#{System.unique_integer([:positive])}@example.com"
|
||||
},
|
||||
actor: admin
|
||||
)
|
||||
|
||||
# Link member to user (User.member_id = member.id)
|
||||
# We use force_change_attribute because the member already exists and we just
|
||||
|
|
@ -108,13 +109,14 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
admin = create_admin_user(actor)
|
||||
|
||||
{:ok, member} =
|
||||
Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Unlinked",
|
||||
last_name: "Member",
|
||||
email: "unlinked#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create(actor: admin)
|
||||
Membership.create_member(
|
||||
%{
|
||||
first_name: "Unlinked",
|
||||
last_name: "Member",
|
||||
email: "unlinked#{System.unique_integer([:positive])}@example.com"
|
||||
},
|
||||
actor: admin
|
||||
)
|
||||
|
||||
member
|
||||
end
|
||||
|
|
@ -145,9 +147,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
# Update is allowed via HasPermission check with :linked scope (not via special case)
|
||||
# The special case policy only applies to :read actions
|
||||
{:ok, updated_member} =
|
||||
linked_member
|
||||
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|
||||
|> Ash.update(actor: user)
|
||||
Membership.update_member(linked_member, %{first_name: "Updated"}, actor: user)
|
||||
|
||||
assert updated_member.first_name == "Updated"
|
||||
end
|
||||
|
|
@ -168,11 +168,8 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
user: user,
|
||||
unlinked_member: unlinked_member
|
||||
} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
unlinked_member
|
||||
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|
||||
|> Ash.update!(actor: user)
|
||||
end
|
||||
assert {:error, %Ash.Error.Forbidden{}} =
|
||||
Membership.update_member(unlinked_member, %{first_name: "Updated"}, actor: user)
|
||||
end
|
||||
|
||||
test "list members returns only linked member", %{
|
||||
|
|
@ -187,15 +184,15 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
end
|
||||
|
||||
test "cannot create member (returns forbidden)", %{user: user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "New",
|
||||
last_name: "Member",
|
||||
email: "new#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create!(actor: user)
|
||||
end
|
||||
assert {:error, %Ash.Error.Forbidden{}} =
|
||||
Membership.create_member(
|
||||
%{
|
||||
first_name: "New",
|
||||
last_name: "Member",
|
||||
email: "new#{System.unique_integer([:positive])}@example.com"
|
||||
},
|
||||
actor: user
|
||||
)
|
||||
end
|
||||
|
||||
test "cannot destroy member (returns forbidden)", %{
|
||||
|
|
@ -245,26 +242,23 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
end
|
||||
|
||||
test "cannot create member (returns forbidden)", %{user: user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "New",
|
||||
last_name: "Member",
|
||||
email: "new#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create!(actor: user)
|
||||
end
|
||||
assert {:error, %Ash.Error.Forbidden{}} =
|
||||
Membership.create_member(
|
||||
%{
|
||||
first_name: "New",
|
||||
last_name: "Member",
|
||||
email: "new#{System.unique_integer([:positive])}@example.com"
|
||||
},
|
||||
actor: user
|
||||
)
|
||||
end
|
||||
|
||||
test "cannot update any member (returns forbidden)", %{
|
||||
user: user,
|
||||
linked_member: linked_member
|
||||
} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
linked_member
|
||||
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|
||||
|> Ash.update!(actor: user)
|
||||
end
|
||||
assert {:error, %Ash.Error.Forbidden{}} =
|
||||
Membership.update_member(linked_member, %{first_name: "Updated"}, actor: user)
|
||||
end
|
||||
|
||||
test "cannot destroy any member (returns forbidden)", %{
|
||||
|
|
@ -305,22 +299,21 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
|
||||
test "can create member", %{user: user} do
|
||||
{:ok, member} =
|
||||
Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "New",
|
||||
last_name: "Member",
|
||||
email: "new#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create(actor: user)
|
||||
Membership.create_member(
|
||||
%{
|
||||
first_name: "New",
|
||||
last_name: "Member",
|
||||
email: "new#{System.unique_integer([:positive])}@example.com"
|
||||
},
|
||||
actor: user
|
||||
)
|
||||
|
||||
assert member.first_name == "New"
|
||||
end
|
||||
|
||||
test "can update any member", %{user: user, unlinked_member: unlinked_member} do
|
||||
{:ok, updated_member} =
|
||||
unlinked_member
|
||||
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|
||||
|> Ash.update(actor: user)
|
||||
Membership.update_member(unlinked_member, %{first_name: "Updated"}, actor: user)
|
||||
|
||||
assert updated_member.first_name == "Updated"
|
||||
end
|
||||
|
|
@ -363,22 +356,21 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
|
||||
test "can create member", %{user: user} do
|
||||
{:ok, member} =
|
||||
Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "New",
|
||||
last_name: "Member",
|
||||
email: "new#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create(actor: user)
|
||||
Membership.create_member(
|
||||
%{
|
||||
first_name: "New",
|
||||
last_name: "Member",
|
||||
email: "new#{System.unique_integer([:positive])}@example.com"
|
||||
},
|
||||
actor: user
|
||||
)
|
||||
|
||||
assert member.first_name == "New"
|
||||
end
|
||||
|
||||
test "can update any member", %{user: user, unlinked_member: unlinked_member} do
|
||||
{:ok, updated_member} =
|
||||
unlinked_member
|
||||
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|
||||
|> Ash.update(actor: user)
|
||||
Membership.update_member(unlinked_member, %{first_name: "Updated"}, actor: user)
|
||||
|
||||
assert updated_member.first_name == "Updated"
|
||||
end
|
||||
|
|
@ -456,9 +448,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
|
||||
# Should succeed via HasPermission check (not special case)
|
||||
{:ok, updated_member} =
|
||||
linked_member
|
||||
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|
||||
|> Ash.update(actor: user)
|
||||
Membership.update_member(linked_member, %{first_name: "Updated"}, actor: user)
|
||||
|
||||
assert updated_member.first_name == "Updated"
|
||||
end
|
||||
|
|
|
|||
|
|
@ -49,10 +49,8 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||
|> Ash.create!(actor: actor)
|
||||
{:ok, member} = Mv.Membership.create_member(attrs, actor: actor)
|
||||
member
|
||||
end
|
||||
|
||||
# Helper to create a member and explicitly generate cycles with a fixed "today" date.
|
||||
|
|
@ -74,9 +72,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
# Assign fee type if provided (this will trigger auto-generation with real today)
|
||||
member =
|
||||
if fee_type_id do
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type_id})
|
||||
|> Ash.update!(actor: actor)
|
||||
{:ok, updated} =
|
||||
Mv.Membership.update_member(member, %{membership_fee_type_id: fee_type_id},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
updated
|
||||
else
|
||||
member
|
||||
end
|
||||
|
|
@ -130,10 +131,8 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
)
|
||||
|
||||
# Assign fee type
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!(actor: actor)
|
||||
{:ok, member} =
|
||||
Mv.Membership.update_member(member, %{membership_fee_type_id: fee_type.id}, actor: actor)
|
||||
|
||||
# Explicitly generate cycles with fixed "today" date
|
||||
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
||||
|
|
@ -163,10 +162,8 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
)
|
||||
|
||||
# Assign fee type
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!(actor: actor)
|
||||
{:ok, member} =
|
||||
Mv.Membership.update_member(member, %{membership_fee_type_id: fee_type.id}, actor: actor)
|
||||
|
||||
# Explicitly generate cycles with fixed "today" date
|
||||
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
||||
|
|
@ -349,10 +346,8 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
|> Ash.create!(actor: actor)
|
||||
|
||||
# Now assign fee type
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!(actor: actor)
|
||||
{:ok, member} =
|
||||
Mv.Membership.update_member(member, %{membership_fee_type_id: fee_type.id}, actor: actor)
|
||||
|
||||
# Explicitly generate cycles with fixed "today" date
|
||||
today = ~D[2024-06-15]
|
||||
|
|
@ -616,13 +611,15 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
)
|
||||
|
||||
# Now assign fee type (simulating a retroactive assignment)
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2021-01-01]
|
||||
})
|
||||
|> Ash.update!(actor: actor)
|
||||
{:ok, member} =
|
||||
Mv.Membership.update_member(
|
||||
member,
|
||||
%{
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2021-01-01]
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Run batch generation with a "today" date after the member left
|
||||
today = ~D[2024-06-15]
|
||||
|
|
|
|||
|
|
@ -40,10 +40,8 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
|||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||
|> Ash.create!(actor: actor)
|
||||
{:ok, member} = Mv.Membership.create_member(attrs, actor: actor)
|
||||
member
|
||||
end
|
||||
|
||||
# Helper to set up settings with specific include_joining_cycle value
|
||||
|
|
@ -81,8 +79,12 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
|||
# Assign fee type
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!(actor: actor)
|
||||
|> then(fn m ->
|
||||
{:ok, updated} =
|
||||
Mv.Membership.update_member(m, %{membership_fee_type_id: fee_type.id}, actor: actor)
|
||||
|
||||
updated
|
||||
end)
|
||||
|
||||
# Explicitly generate cycles with fixed "today" date to avoid date dependency
|
||||
today = ~D[2024-06-15]
|
||||
|
|
@ -128,8 +130,12 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
|||
# Now assign fee type to member
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!(actor: actor)
|
||||
|> then(fn m ->
|
||||
{:ok, updated} =
|
||||
Mv.Membership.update_member(m, %{membership_fee_type_id: fee_type.id}, actor: actor)
|
||||
|
||||
updated
|
||||
end)
|
||||
|
||||
# Generate cycles with specific "today" date
|
||||
today = ~D[2024-06-15]
|
||||
|
|
@ -237,8 +243,12 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
|||
# start from the last existing cycle (2023) and go forward
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!(actor: actor)
|
||||
|> then(fn m ->
|
||||
{:ok, updated} =
|
||||
Mv.Membership.update_member(m, %{membership_fee_type_id: fee_type.id}, actor: actor)
|
||||
|
||||
updated
|
||||
end)
|
||||
|
||||
# Verify gap was NOT filled and new cycles were generated from last existing
|
||||
all_cycles = get_member_cycles(member.id, actor)
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@ defmodule MvWeb.Components.MemberFilterComponentTest do
|
|||
|
||||
alias Mv.Membership.CustomField
|
||||
|
||||
# Helper to create a boolean custom field
|
||||
# Helper to create a boolean custom field (uses system_actor - only admin can create)
|
||||
defp create_boolean_custom_field(attrs \\ %{}) do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
default_attrs = %{
|
||||
name: "test_boolean_#{System.unique_integer([:positive])}",
|
||||
value_type: :boolean
|
||||
|
|
@ -27,11 +29,13 @@ defmodule MvWeb.Components.MemberFilterComponentTest do
|
|||
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, attrs)
|
||||
|> Ash.create!()
|
||||
|> Ash.create!(actor: system_actor)
|
||||
end
|
||||
|
||||
# Helper to create a non-boolean custom field
|
||||
# Helper to create a non-boolean custom field (uses system_actor - only admin can create)
|
||||
defp create_string_custom_field(attrs \\ %{}) do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
default_attrs = %{
|
||||
name: "test_string_#{System.unique_integer([:positive])}",
|
||||
value_type: :string
|
||||
|
|
@ -41,7 +45,7 @@ defmodule MvWeb.Components.MemberFilterComponentTest do
|
|||
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, attrs)
|
||||
|> Ash.create!()
|
||||
|> Ash.create!(actor: system_actor)
|
||||
end
|
||||
|
||||
describe "rendering" do
|
||||
|
|
|
|||
|
|
@ -80,21 +80,20 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
|||
|> Ash.create!(actor: actor)
|
||||
|
||||
# Create member without fee type first to avoid auto-generation
|
||||
member =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2022-01-01]
|
||||
})
|
||||
|> Ash.create!(actor: actor)
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2022-01-01]
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Assign fee type after member creation (this may generate cycles, but we'll create our own)
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!(actor: actor)
|
||||
{:ok, member} =
|
||||
Mv.Membership.update_member(member, %{membership_fee_type_id: fee_type.id}, actor: actor)
|
||||
|
||||
# Delete any auto-generated cycles first
|
||||
cycles =
|
||||
|
|
@ -151,20 +150,19 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
|||
|> Ash.create!(actor: actor)
|
||||
|
||||
# Create member without fee type first
|
||||
member =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create!(actor: actor)
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com"
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Assign fee type
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!(actor: actor)
|
||||
{:ok, member} =
|
||||
Mv.Membership.update_member(member, %{membership_fee_type_id: fee_type.id}, actor: actor)
|
||||
|
||||
# Delete any auto-generated cycles
|
||||
cycles =
|
||||
|
|
@ -197,21 +195,20 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
|||
|> Ash.create!(actor: actor)
|
||||
|
||||
# Create member without fee type first
|
||||
member =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2023-01-01]
|
||||
})
|
||||
|> Ash.create!(actor: actor)
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2023-01-01]
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Assign fee type
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!(actor: actor)
|
||||
{:ok, member} =
|
||||
Mv.Membership.update_member(member, %{membership_fee_type_id: fee_type.id}, actor: actor)
|
||||
|
||||
# Delete any auto-generated cycles
|
||||
cycles =
|
||||
|
|
|
|||
|
|
@ -15,13 +15,15 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
|||
use MvWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
admin_role = Mv.Fixtures.role_fixture("admin")
|
||||
|
||||
# Create admin user for testing
|
||||
# Create admin user for testing (must have admin role to read/manage CustomField)
|
||||
{:ok, user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||
|
|
@ -30,8 +32,18 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
|||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
|
||||
conn = log_in_user(build_conn(), user)
|
||||
%{conn: conn, user: user}
|
||||
{:ok, user} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update(actor: system_actor)
|
||||
|
||||
user_with_role = Ash.load!(user, :role, domain: Mv.Accounts, actor: system_actor)
|
||||
conn = log_in_user(build_conn(), user_with_role)
|
||||
# Use English locale so "Delete" link text matches in assertions
|
||||
session = conn.private[:plug_session] || %{}
|
||||
conn = Plug.Test.init_test_session(conn, Map.put(session, "locale", "en"))
|
||||
%{conn: conn, user: user_with_role}
|
||||
end
|
||||
|
||||
describe "delete button and modal" do
|
||||
|
|
@ -107,9 +119,8 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
|||
|> element("#delete-custom-field-modal form")
|
||||
|> render_change(%{"slug" => custom_field.slug})
|
||||
|
||||
# Confirm button should be enabled now (no disabled attribute)
|
||||
html = render(view)
|
||||
refute html =~ ~r/disabled(?:=""|(?!\w))/
|
||||
# Confirm button should be enabled now (no disabled attribute on the confirm button)
|
||||
refute has_element?(view, "#delete-custom-field-modal button.btn-error[disabled]")
|
||||
end
|
||||
|
||||
test "delete button is disabled when slug doesn't match", %{conn: conn} do
|
||||
|
|
@ -126,9 +137,8 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
|||
|> element("#delete-custom-field-modal form")
|
||||
|> render_change(%{"slug" => "wrong-slug"})
|
||||
|
||||
# Button should be disabled
|
||||
html = render(view)
|
||||
assert html =~ ~r/disabled(?:=""|(?!\w))/
|
||||
# Confirm button should be disabled
|
||||
assert has_element?(view, "#delete-custom-field-modal button.btn-error[disabled]")
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -186,10 +196,8 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
|||
|> element("#delete-custom-field-modal form")
|
||||
|> render_change(%{"slug" => "wrong-slug"})
|
||||
|
||||
# Button should be disabled and we cannot click it
|
||||
# The test verifies that the button is properly disabled in the UI
|
||||
html = render(view)
|
||||
assert html =~ ~r/disabled(?:=""|(?!\w))/
|
||||
# Confirm button should be disabled and we cannot click it
|
||||
assert has_element?(view, "#delete-custom-field-modal button.btn-error[disabled]")
|
||||
|
||||
# Custom field should still exist since deletion couldn't proceed
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
|
@ -224,17 +232,58 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
|||
end
|
||||
end
|
||||
|
||||
# Helper functions
|
||||
describe "create custom field" do
|
||||
test "submitting new data field form creates custom field and shows success", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Open "New Data Field" form
|
||||
view
|
||||
|> element("#custom-fields-component button", "New Data Field")
|
||||
|> render_click()
|
||||
|
||||
# Form is visible; submit with valid data
|
||||
form_params = %{
|
||||
"custom_field" => %{
|
||||
"name" => "Created via Form",
|
||||
"value_type" => "string",
|
||||
"description" => "",
|
||||
"required" => "false",
|
||||
"show_in_overview" => "true"
|
||||
}
|
||||
}
|
||||
|
||||
view
|
||||
|> form("#custom-field-form-new-form", form_params)
|
||||
|> render_submit()
|
||||
|
||||
# Success flash (FormComponent needs actor from parent; without it KeyError would occur)
|
||||
assert render(view) =~ "successfully"
|
||||
|
||||
# Custom field was created in DB
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
search_name = "Created via Form"
|
||||
|
||||
[custom_field] =
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Query.filter(name == ^search_name)
|
||||
|> Ash.read!(actor: system_actor)
|
||||
|
||||
assert custom_field.value_type == :string
|
||||
end
|
||||
end
|
||||
|
||||
# Helper functions (use code interface so actor is in validation context)
|
||||
defp create_member do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "User#{System.unique_integer([:positive])}",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "User#{System.unique_integer([:positive])}",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
end
|
||||
|
||||
defp create_custom_field(name, value_type) do
|
||||
|
|
|
|||
|
|
@ -115,15 +115,35 @@ defmodule MvWeb.GroupLive.IndexTest do
|
|||
end
|
||||
|
||||
describe "performance" do
|
||||
@describetag :slow
|
||||
test "page loads efficiently with many groups", %{conn: conn} do
|
||||
# Create multiple groups
|
||||
Enum.each(1..10, fn _ -> Fixtures.group_fixture() end)
|
||||
|
||||
# Count queries using Telemetry
|
||||
query_count_agent = Agent.start_link(fn -> 0 end) |> elem(1)
|
||||
|
||||
handler = fn _event, _measurements, _metadata, _config ->
|
||||
Agent.update(query_count_agent, &(&1 + 1))
|
||||
end
|
||||
|
||||
handler_id = "test-query-counter-#{System.unique_integer([:positive])}"
|
||||
:telemetry.attach(handler_id, [:ash, :query, :start], handler, nil)
|
||||
|
||||
# Should load without N+1 queries
|
||||
{:ok, _view, html} = live(conn, "/groups")
|
||||
|
||||
final_count = Agent.get(query_count_agent, & &1)
|
||||
:telemetry.detach(handler_id)
|
||||
|
||||
# Verify all groups are displayed
|
||||
assert html =~ gettext("Groups")
|
||||
|
||||
# Verify query count is reasonable (should avoid N+1 queries)
|
||||
# Expected: 1 query for groups list + 1 batch query for member counts + LiveView setup queries
|
||||
# Allow overhead for authorization, LiveView setup, and other initialization queries
|
||||
assert final_count <= 12,
|
||||
"Expected max 12 queries (groups list + batch member counts + LiveView setup + auth), got #{final_count}. This suggests N+1 query problem."
|
||||
end
|
||||
|
||||
test "member count is loaded efficiently via calculation", %{conn: conn} do
|
||||
|
|
@ -142,10 +162,29 @@ defmodule MvWeb.GroupLive.IndexTest do
|
|||
actor: system_actor
|
||||
)
|
||||
|
||||
# Count queries using Telemetry
|
||||
query_count_agent = Agent.start_link(fn -> 0 end) |> elem(1)
|
||||
|
||||
handler = fn _event, _measurements, _metadata, _config ->
|
||||
Agent.update(query_count_agent, &(&1 + 1))
|
||||
end
|
||||
|
||||
handler_id = "test-query-counter-#{System.unique_integer([:positive])}"
|
||||
:telemetry.attach(handler_id, [:ash, :query, :start], handler, nil)
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups")
|
||||
|
||||
final_count = Agent.get(query_count_agent, & &1)
|
||||
:telemetry.detach(handler_id)
|
||||
|
||||
# Member count should be displayed (should be 2)
|
||||
assert html =~ "2" or html =~ gettext("Members") or html =~ "Mitglieder"
|
||||
|
||||
# Verify query count is reasonable (member count should be calculated efficiently)
|
||||
# Expected: 1 query for groups + 1 batch query for member counts + LiveView setup queries
|
||||
# Allow overhead for authorization, LiveView setup, and other initialization queries
|
||||
assert final_count <= 12,
|
||||
"Expected max 12 queries (groups + batch member counts + LiveView setup + auth), got #{final_count}. This suggests inefficient member count calculation."
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ defmodule MvWeb.GroupLive.ShowTest do
|
|||
end
|
||||
|
||||
describe "performance" do
|
||||
@describetag :slow
|
||||
test "member list is loaded efficiently (no N+1 queries)", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
|
||||
|
|
@ -235,12 +236,32 @@ defmodule MvWeb.GroupLive.ShowTest do
|
|||
)
|
||||
end)
|
||||
|
||||
# Count queries using Telemetry
|
||||
query_count_agent = Agent.start_link(fn -> 0 end) |> elem(1)
|
||||
|
||||
handler = fn _event, _measurements, _metadata, _config ->
|
||||
Agent.update(query_count_agent, &(&1 + 1))
|
||||
end
|
||||
|
||||
handler_id = "test-query-counter-#{System.unique_integer([:positive])}"
|
||||
:telemetry.attach(handler_id, [:ash, :query, :start], handler, nil)
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
final_count = Agent.get(query_count_agent, & &1)
|
||||
:telemetry.detach(handler_id)
|
||||
|
||||
# All members should be displayed
|
||||
Enum.each(members, fn member ->
|
||||
assert html =~ member.first_name or html =~ member.last_name
|
||||
end)
|
||||
|
||||
# Verify query count is reasonable (should avoid N+1 queries)
|
||||
# Expected: 1 query for group lookup + 1 query for members (with preload) + member_count aggregate
|
||||
# Allow overhead for authorization, LiveView setup, and other initialization queries
|
||||
# Note: member_count aggregate and authorization checks may add additional queries
|
||||
assert final_count <= 20,
|
||||
"Expected max 20 queries (group + members preload + member_count aggregate + LiveView setup + auth), got #{final_count}. This suggests N+1 query problem."
|
||||
end
|
||||
|
||||
test "slug lookup is efficient (uses unique_slug index)", %{conn: conn} do
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|
|||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.Membership.Member
|
||||
|
||||
require Ash.Query
|
||||
|
||||
|
|
@ -55,10 +54,8 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|
|||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||
|> Ash.create!(actor: system_actor)
|
||||
{:ok, member} = Mv.Membership.create_member(attrs, actor: system_actor)
|
||||
member
|
||||
end
|
||||
|
||||
describe "create form" do
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|
|||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.Membership.Member
|
||||
|
||||
require Ash.Query
|
||||
|
||||
|
|
@ -31,7 +30,7 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|
|||
end
|
||||
|
||||
# Helper to create a member
|
||||
# Uses admin actor from global setup to ensure authorization
|
||||
# Uses admin actor from global setup to ensure authorization; falls back to system_actor when nil
|
||||
defp create_member(attrs, actor) do
|
||||
default_attrs = %{
|
||||
first_name: "Test",
|
||||
|
|
@ -40,12 +39,9 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|
|||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
opts = if actor, do: [actor: actor], else: []
|
||||
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||
|> Ash.create!(opts)
|
||||
effective_actor = actor || Mv.Helpers.SystemActor.get_system_actor()
|
||||
{:ok, member} = Mv.Membership.create_member(attrs, actor: effective_actor)
|
||||
member
|
||||
end
|
||||
|
||||
describe "list display" do
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ defmodule MvWeb.RoleLive.ShowTest do
|
|||
%{conn: conn, actor: system_actor}
|
||||
end
|
||||
|
||||
@tag :slow
|
||||
test "mounts successfully with valid role ID", %{conn: conn} do
|
||||
role = create_role()
|
||||
|
||||
|
|
@ -101,6 +102,7 @@ defmodule MvWeb.RoleLive.ShowTest do
|
|||
assert html =~ role.name
|
||||
end
|
||||
|
||||
@tag :slow
|
||||
test "displays role name", %{conn: conn} do
|
||||
role = create_role(%{name: "Test Role Name"})
|
||||
|
||||
|
|
@ -127,6 +129,7 @@ defmodule MvWeb.RoleLive.ShowTest do
|
|||
assert html =~ gettext("No description")
|
||||
end
|
||||
|
||||
@tag :slow
|
||||
test "displays permission set name", %{conn: conn} do
|
||||
role = create_role(%{permission_set_name: "read_only"})
|
||||
|
||||
|
|
@ -152,6 +155,7 @@ defmodule MvWeb.RoleLive.ShowTest do
|
|||
assert html =~ gettext("Yes")
|
||||
end
|
||||
|
||||
@tag :slow
|
||||
test "displays non-system role badge when is_system_role is false", %{conn: conn} do
|
||||
role = create_role()
|
||||
|
||||
|
|
@ -178,6 +182,7 @@ defmodule MvWeb.RoleLive.ShowTest do
|
|||
%{conn: conn, actor: system_actor}
|
||||
end
|
||||
|
||||
@tag :slow
|
||||
test "back button navigates to role list", %{conn: conn} do
|
||||
role = create_role()
|
||||
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ defmodule MvWeb.RoleLiveTest do
|
|||
%{conn: conn, actor: system_actor, user: user}
|
||||
end
|
||||
|
||||
@tag :slow
|
||||
test "mounts successfully", %{conn: conn} do
|
||||
{:ok, _view, _html} = live(conn, "/admin/roles")
|
||||
end
|
||||
|
|
@ -388,6 +389,7 @@ defmodule MvWeb.RoleLiveTest do
|
|||
%{conn: conn, actor: system_actor, user: user}
|
||||
end
|
||||
|
||||
@tag :slow
|
||||
test "deletes non-system role", %{conn: conn} do
|
||||
role = create_role()
|
||||
|
||||
|
|
|
|||
|
|
@ -23,17 +23,12 @@ defmodule MvWeb.UserLive.ShowTest do
|
|||
end
|
||||
|
||||
describe "mount and display" do
|
||||
test "mounts successfully with valid user ID", %{conn: conn, user: user} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, ~p"/users/#{user.id}")
|
||||
|
||||
assert html =~ to_string(user.email)
|
||||
end
|
||||
|
||||
test "displays user email", %{conn: conn, user: user} do
|
||||
@tag :ui
|
||||
test "mounts successfully and displays user information", %{conn: conn, user: user} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, ~p"/users/#{user.id}")
|
||||
|
||||
# Basic display
|
||||
assert html =~ to_string(user.email)
|
||||
assert html =~ gettext("Email")
|
||||
end
|
||||
|
|
@ -63,18 +58,16 @@ defmodule MvWeb.UserLive.ShowTest do
|
|||
assert html =~ gettext("Not enabled")
|
||||
end
|
||||
|
||||
@tag :slow
|
||||
test "displays linked member when present", %{conn: conn} do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
# Create member
|
||||
{:ok, member} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Alice",
|
||||
last_name: "Smith",
|
||||
email: "alice@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Alice", last_name: "Smith", email: "alice@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
# Create user and link to member
|
||||
user = create_test_user(%{email: "user@example.com"})
|
||||
|
|
@ -103,10 +96,12 @@ defmodule MvWeb.UserLive.ShowTest do
|
|||
end
|
||||
|
||||
describe "navigation" do
|
||||
test "back button navigates to user list", %{conn: conn, user: user} do
|
||||
@tag :ui
|
||||
test "navigation buttons work correctly", %{conn: conn, user: user} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, ~p"/users/#{user.id}")
|
||||
|
||||
# Back button navigates to user list
|
||||
assert {:error, {:live_redirect, %{to: to}}} =
|
||||
view
|
||||
|> element(
|
||||
|
|
@ -115,10 +110,8 @@ defmodule MvWeb.UserLive.ShowTest do
|
|||
|> render_click()
|
||||
|
||||
assert to == "/users"
|
||||
end
|
||||
|
||||
test "edit button navigates to edit form", %{conn: conn, user: user} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
# Edit button navigates to edit form
|
||||
{:ok, view, _html} = live(conn, ~p"/users/#{user.id}")
|
||||
|
||||
assert {:error, {:live_redirect, %{to: to}}} =
|
||||
|
|
@ -132,6 +125,17 @@ defmodule MvWeb.UserLive.ShowTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "page title" do
|
||||
@tag :ui
|
||||
test "sets correct page title", %{conn: conn, user: user} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, ~p"/users/#{user.id}")
|
||||
|
||||
# Check that page title is set (might be in title tag or header)
|
||||
assert html =~ gettext("Show User") || html =~ to_string(user.email)
|
||||
end
|
||||
end
|
||||
|
||||
describe "error handling" do
|
||||
test "raises exception for invalid user ID", %{conn: conn} do
|
||||
invalid_id = Ecto.UUID.generate()
|
||||
|
|
@ -145,17 +149,8 @@ defmodule MvWeb.UserLive.ShowTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "page title" do
|
||||
test "sets correct page title", %{conn: conn, user: user} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, ~p"/users/#{user.id}")
|
||||
|
||||
# Check that page title is set (might be in title tag or header)
|
||||
assert html =~ gettext("Show User") || html =~ to_string(user.email)
|
||||
end
|
||||
end
|
||||
|
||||
describe "system actor user" do
|
||||
@tag :slow
|
||||
test "redirects to user list when viewing system actor user", %{conn: conn} do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
|
|||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Membership.Member
|
||||
|
||||
require Ash.Query
|
||||
|
||||
describe "error handling - flash messages" do
|
||||
|
|
@ -16,13 +14,14 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
|
|||
|
||||
# Create a member with the same email to trigger uniqueness error
|
||||
{:ok, _existing_member} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Existing",
|
||||
last_name: "Member",
|
||||
email: "duplicate@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Existing",
|
||||
last_name: "Member",
|
||||
email: "duplicate@example.com"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members/new")
|
||||
|
|
@ -79,23 +78,21 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
|
|||
|
||||
# Create a member to edit
|
||||
{:ok, member} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Original",
|
||||
last_name: "Member",
|
||||
email: "original@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Original",
|
||||
last_name: "Member",
|
||||
email: "original@example.com"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
# Create another member with different email
|
||||
{:ok, _other_member} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Other",
|
||||
last_name: "Member",
|
||||
email: "other@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Other", last_name: "Member", email: "other@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|
|||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
|
||||
require Ash.Query
|
||||
|
|
@ -37,10 +36,8 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|
|||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||
|> Ash.create!(actor: admin_user)
|
||||
{:ok, member} = Mv.Membership.create_member(attrs, actor: admin_user)
|
||||
member
|
||||
end
|
||||
|
||||
describe "membership fee type dropdown" do
|
||||
|
|
@ -129,7 +126,7 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|
|||
|
||||
# Verify member was created with fee type - use admin_user to test permissions
|
||||
member =
|
||||
Member
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.filter(email == ^form_data["member[email]"])
|
||||
|> Ash.read_one!(actor: admin_user)
|
||||
|
||||
|
|
@ -177,14 +174,15 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|
|||
|
||||
# Create member with fee type 1 and custom field value
|
||||
member =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
membership_fee_type_id: fee_type1.id
|
||||
})
|
||||
|> Ash.create!(actor: admin_user)
|
||||
create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
membership_fee_type_id: fee_type1.id
|
||||
},
|
||||
admin_user
|
||||
)
|
||||
|
||||
# Add custom field value
|
||||
Mv.Membership.CustomFieldValue
|
||||
|
|
@ -222,14 +220,15 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|
|||
|
||||
# Create member with date custom field value
|
||||
member =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|> Ash.create!(actor: admin_user)
|
||||
create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
membership_fee_type_id: fee_type.id
|
||||
},
|
||||
admin_user
|
||||
)
|
||||
|
||||
test_date = ~D[2024-01-15]
|
||||
|
||||
|
|
@ -269,14 +268,15 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|
|||
|
||||
# Create member with custom field value
|
||||
member =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|> Ash.create!(actor: admin_user)
|
||||
create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
membership_fee_type_id: fee_type.id
|
||||
},
|
||||
admin_user
|
||||
)
|
||||
|
||||
# Add custom field value
|
||||
_cfv =
|
||||
|
|
|
|||
|
|
@ -39,10 +39,8 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||
|> Ash.create!(actor: system_actor)
|
||||
{:ok, member} = Mv.Membership.create_member(attrs, actor: system_actor)
|
||||
member
|
||||
end
|
||||
|
||||
# Helper to create a cycle
|
||||
|
|
@ -106,8 +104,14 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
# Assign fee type
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!(actor: system_actor)
|
||||
|> then(fn m ->
|
||||
{:ok, updated} =
|
||||
Mv.Membership.update_member(m, %{membership_fee_type_id: fee_type.id},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
updated
|
||||
end)
|
||||
|
||||
# Delete any auto-generated cycles
|
||||
cycles =
|
||||
|
|
@ -151,8 +155,14 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
# Assign fee type
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!(actor: system_actor)
|
||||
|> then(fn m ->
|
||||
{:ok, updated} =
|
||||
Mv.Membership.update_member(m, %{membership_fee_type_id: fee_type.id},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
updated
|
||||
end)
|
||||
|
||||
# Delete any auto-generated cycles
|
||||
cycles =
|
||||
|
|
@ -192,8 +202,14 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
# Assign fee type
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!(actor: system_actor)
|
||||
|> then(fn m ->
|
||||
{:ok, updated} =
|
||||
Mv.Membership.update_member(m, %{membership_fee_type_id: fee_type.id},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
updated
|
||||
end)
|
||||
|
||||
# Delete any auto-generated cycles
|
||||
cycles =
|
||||
|
|
|
|||
|
|
@ -11,20 +11,17 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do
|
|||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
||||
alias Mv.Membership.{CustomField, CustomFieldValue}
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
# Create test member
|
||||
{:ok, member} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Alice",
|
||||
last_name: "Anderson",
|
||||
email: "alice@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
# Create custom field with show_in_overview: true
|
||||
{:ok, field} =
|
||||
|
|
|
|||
|
|
@ -14,29 +14,23 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
|
|||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
||||
alias Mv.Membership.{CustomField, CustomFieldValue}
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
# Create test members
|
||||
{:ok, member1} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Alice",
|
||||
last_name: "Anderson",
|
||||
email: "alice@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, member2} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Bob",
|
||||
last_name: "Brown",
|
||||
email: "bob@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Bob", last_name: "Brown", email: "bob@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
# Create custom fields
|
||||
{:ok, field_show_string} =
|
||||
|
|
@ -161,6 +155,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
|
|||
}
|
||||
end
|
||||
|
||||
@tag :slow
|
||||
test "displays custom field with show_in_overview: true", %{
|
||||
conn: conn,
|
||||
member1: _member1,
|
||||
|
|
@ -229,6 +224,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
|
|||
assert html =~ "true" or html =~ "Yes" or html =~ "Ja"
|
||||
end
|
||||
|
||||
@tag :slow
|
||||
test "formats date custom field values correctly", %{conn: conn, member1: _member1} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
|
@ -237,6 +233,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
|
|||
assert html =~ "15.05.1990"
|
||||
end
|
||||
|
||||
@tag :slow
|
||||
test "formats email custom field values correctly", %{conn: conn, member1: _member1} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
|
|
|||
|
|
@ -10,29 +10,24 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
|
|||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Membership.{CustomField, Member}
|
||||
alias Mv.Membership.CustomField
|
||||
|
||||
@tag :slow
|
||||
test "displays custom field column even when no members have values", %{conn: conn} do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
# Create test members without custom field values
|
||||
{:ok, _member1} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Alice",
|
||||
last_name: "Anderson",
|
||||
email: "alice@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, _member2} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Bob",
|
||||
last_name: "Brown",
|
||||
email: "bob@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Bob", last_name: "Brown", email: "bob@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
# Create custom field with show_in_overview: true but no values
|
||||
{:ok, field} =
|
||||
|
|
@ -51,18 +46,16 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
|
|||
assert html =~ field.name
|
||||
end
|
||||
|
||||
@tag :slow
|
||||
test "displays very long custom field values correctly", %{conn: conn} do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
# Create test member
|
||||
{:ok, member} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Alice",
|
||||
last_name: "Anderson",
|
||||
email: "alice@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
# Create custom field
|
||||
{:ok, field} =
|
||||
|
|
@ -94,18 +87,16 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
|
|||
assert html =~ "A" or html =~ long_value
|
||||
end
|
||||
|
||||
@tag :slow
|
||||
test "handles multiple custom fields with show_in_overview correctly", %{conn: conn} do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
# Create test member
|
||||
{:ok, member} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Alice",
|
||||
last_name: "Anderson",
|
||||
email: "alice@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
# Create multiple custom fields with show_in_overview: true
|
||||
{:ok, field1} =
|
||||
|
|
|
|||
|
|
@ -13,38 +13,29 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
|||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
||||
alias Mv.Membership.{CustomField, CustomFieldValue}
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
# Create test members
|
||||
{:ok, member1} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Alice",
|
||||
last_name: "Anderson",
|
||||
email: "alice@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, member2} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Bob",
|
||||
last_name: "Brown",
|
||||
email: "bob@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Bob", last_name: "Brown", email: "bob@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, member3} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Charlie",
|
||||
last_name: "Clark",
|
||||
email: "charlie@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Charlie", last_name: "Clark", email: "charlie@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
# Create custom field with show_in_overview: true
|
||||
{:ok, field_string} =
|
||||
|
|
@ -242,40 +233,28 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
|||
|
||||
# Create additional members with NULL and empty string values
|
||||
{:ok, member_with_value} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "WithValue",
|
||||
last_name: "Test",
|
||||
email: "withvalue@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "WithValue", last_name: "Test", email: "withvalue@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, member_with_empty} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "WithEmpty",
|
||||
last_name: "Test",
|
||||
email: "withempty@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "WithEmpty", last_name: "Test", email: "withempty@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, member_with_null} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "WithNull",
|
||||
last_name: "Test",
|
||||
email: "withnull@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "WithNull", last_name: "Test", email: "withnull@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, member_with_another_value} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "AnotherValue",
|
||||
last_name: "Test",
|
||||
email: "another@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "AnotherValue", last_name: "Test", email: "another@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
# Create custom field
|
||||
{:ok, field} =
|
||||
|
|
@ -355,40 +334,28 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
|||
|
||||
# Create additional members with NULL and empty string values
|
||||
{:ok, member_with_value} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "WithValue",
|
||||
last_name: "Test",
|
||||
email: "withvalue@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "WithValue", last_name: "Test", email: "withvalue@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, member_with_empty} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "WithEmpty",
|
||||
last_name: "Test",
|
||||
email: "withempty@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "WithEmpty", last_name: "Test", email: "withempty@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, member_with_null} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "WithNull",
|
||||
last_name: "Test",
|
||||
email: "withnull@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "WithNull", last_name: "Test", email: "withnull@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, member_with_another_value} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "AnotherValue",
|
||||
last_name: "Test",
|
||||
email: "another@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "AnotherValue", last_name: "Test", email: "another@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
# Create custom field
|
||||
{:ok, field} =
|
||||
|
|
|
|||
|
|
@ -16,33 +16,35 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
|||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
||||
alias Mv.Membership.{CustomField, CustomFieldValue}
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
# Create test members
|
||||
{:ok, member1} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Alice",
|
||||
last_name: "Anderson",
|
||||
email: "alice@example.com",
|
||||
street: "Main St",
|
||||
city: "Berlin"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Alice",
|
||||
last_name: "Anderson",
|
||||
email: "alice@example.com",
|
||||
street: "Main St",
|
||||
city: "Berlin"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, member2} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Bob",
|
||||
last_name: "Brown",
|
||||
email: "bob@example.com",
|
||||
street: "Second St",
|
||||
city: "Hamburg"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Bob",
|
||||
last_name: "Brown",
|
||||
email: "bob@example.com",
|
||||
street: "Second St",
|
||||
city: "Hamburg"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
# Create custom field
|
||||
{:ok, custom_field} =
|
||||
|
|
|
|||
|
|
@ -3,33 +3,29 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
|
|||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Membership.Member
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, member1} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Alice",
|
||||
last_name: "Anderson",
|
||||
email: "alice@example.com",
|
||||
street: "Main Street",
|
||||
house_number: "123",
|
||||
postal_code: "12345",
|
||||
city: "Berlin",
|
||||
join_date: ~D[2020-01-15]
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Alice",
|
||||
last_name: "Anderson",
|
||||
email: "alice@example.com",
|
||||
street: "Main Street",
|
||||
house_number: "123",
|
||||
postal_code: "12345",
|
||||
city: "Berlin",
|
||||
join_date: ~D[2020-01-15]
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, member2} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Bob",
|
||||
last_name: "Brown",
|
||||
email: "bob@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Bob", last_name: "Brown", email: "bob@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
%{
|
||||
member1: member1,
|
||||
|
|
@ -56,7 +52,9 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
|
|||
})
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
# Use search query to filter to only the expected member (Alice)
|
||||
# This significantly improves test performance by avoiding loading all members from other tests
|
||||
{:ok, _view, html} = live(conn, "/members?query=Alice")
|
||||
|
||||
assert html =~ "Email"
|
||||
assert html =~ m.email
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
|
|||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
|
||||
|
|
@ -40,10 +39,8 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
|
|||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||
|> Ash.create!(actor: system_actor)
|
||||
{:ok, member} = Mv.Membership.create_member(attrs, actor: system_actor)
|
||||
member
|
||||
end
|
||||
|
||||
# Helper to create a cycle
|
||||
|
|
@ -238,6 +235,7 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
|
|||
end
|
||||
end
|
||||
|
||||
@moduletag :slow
|
||||
describe "performance" do
|
||||
test "loads cycles efficiently without N+1 queries", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
|
|
|||
|
|
@ -531,10 +531,8 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||
|> Ash.create!(actor: actor)
|
||||
{:ok, member} = Mv.Membership.create_member(attrs, actor: actor)
|
||||
member
|
||||
end
|
||||
|
||||
test "filter shows only members with paid status in last cycle", %{conn: conn} do
|
||||
|
|
@ -750,8 +748,10 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
describe "boolean custom field filters" do
|
||||
alias Mv.Membership.CustomField
|
||||
|
||||
# Helper to create a boolean custom field
|
||||
# Helper to create a boolean custom field (uses system actor for authorization)
|
||||
defp create_boolean_custom_field(attrs \\ %{}) do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
default_attrs = %{
|
||||
name: "test_boolean_#{System.unique_integer([:positive])}",
|
||||
value_type: :boolean
|
||||
|
|
@ -761,11 +761,13 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, attrs)
|
||||
|> Ash.create!()
|
||||
|> Ash.create!(actor: system_actor)
|
||||
end
|
||||
|
||||
# Helper to create a non-boolean custom field
|
||||
# Helper to create a non-boolean custom field (uses system actor for authorization)
|
||||
defp create_string_custom_field(attrs \\ %{}) do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
default_attrs = %{
|
||||
name: "test_string_#{System.unique_integer([:positive])}",
|
||||
value_type: :string
|
||||
|
|
@ -775,7 +777,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, attrs)
|
||||
|> Ash.create!()
|
||||
|> Ash.create!(actor: system_actor)
|
||||
end
|
||||
|
||||
test "mount initializes boolean_custom_field_filters as empty map", %{conn: conn} do
|
||||
|
|
@ -1016,6 +1018,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
|
||||
test "handle_params removes filter when custom field is deleted", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
boolean_field = create_boolean_custom_field()
|
||||
|
||||
# Set up filter via URL
|
||||
|
|
@ -1026,8 +1029,8 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
filters_before = state_before.socket.assigns.boolean_custom_field_filters
|
||||
assert filters_before[boolean_field.id] == true
|
||||
|
||||
# Delete the custom field
|
||||
Ash.destroy!(boolean_field)
|
||||
# Delete the custom field (requires actor with destroy permission)
|
||||
Ash.destroy!(boolean_field, actor: system_actor)
|
||||
|
||||
# Navigate again - filter should be removed since custom field no longer exists
|
||||
{:ok, view2, _html} =
|
||||
|
|
@ -1122,18 +1125,15 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
|
||||
# Helper to create a member with a boolean custom field value
|
||||
defp create_member_with_boolean_value(member_attrs, custom_field, value, actor) do
|
||||
{:ok, member} =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(
|
||||
:create_member,
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||||
}
|
||||
|> Map.merge(member_attrs)
|
||||
)
|
||||
|> Ash.create(actor: actor)
|
||||
attrs =
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||||
}
|
||||
|> Map.merge(member_attrs)
|
||||
|
||||
{:ok, member} = Mv.Membership.create_member(attrs, actor: actor)
|
||||
|
||||
{:ok, _cfv} =
|
||||
Mv.Membership.CustomFieldValue
|
||||
|
|
@ -1177,13 +1177,14 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
boolean_field = create_boolean_custom_field()
|
||||
|
||||
{:ok, member} =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
# Create CustomFieldValue with map format (Ash expects _union_type and _union_value)
|
||||
{:ok, _cfv} =
|
||||
|
|
@ -1210,13 +1211,14 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
boolean_field = create_boolean_custom_field()
|
||||
|
||||
{:ok, member} =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
# Member has no custom field value for this field
|
||||
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
|
||||
|
|
@ -1233,13 +1235,14 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
boolean_field = create_boolean_custom_field()
|
||||
|
||||
{:ok, member} =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
# Create CustomFieldValue with nil value (edge case)
|
||||
{:ok, _cfv} =
|
||||
|
|
@ -1266,13 +1269,14 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
boolean_field = create_boolean_custom_field()
|
||||
|
||||
{:ok, member} =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
# Create string custom field value (not boolean)
|
||||
{:ok, _cfv} =
|
||||
|
|
@ -1315,20 +1319,21 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
)
|
||||
|
||||
{:ok, member_without_value} =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "NoValue",
|
||||
last_name: "Member",
|
||||
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "NoValue",
|
||||
last_name: "Member",
|
||||
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
member_without_value =
|
||||
member_without_value |> Ash.load!(:custom_field_values, actor: system_actor)
|
||||
|
||||
members = [member_with_true, member_with_false, member_without_value]
|
||||
filters = %{to_string(boolean_field.id) => true}
|
||||
all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
|
||||
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor)
|
||||
|
||||
result =
|
||||
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
|
||||
|
|
@ -1365,20 +1370,21 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
)
|
||||
|
||||
{:ok, member_without_value} =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "NoValue",
|
||||
last_name: "Member",
|
||||
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "NoValue",
|
||||
last_name: "Member",
|
||||
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
member_without_value =
|
||||
member_without_value |> Ash.load!(:custom_field_values, actor: system_actor)
|
||||
|
||||
members = [member_with_true, member_with_false, member_without_value]
|
||||
filters = %{to_string(boolean_field.id) => false}
|
||||
all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
|
||||
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor)
|
||||
|
||||
result =
|
||||
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
|
||||
|
|
@ -1417,7 +1423,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
|
||||
members = [member1, member2]
|
||||
filters = %{}
|
||||
all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
|
||||
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor)
|
||||
|
||||
result =
|
||||
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
|
||||
|
|
@ -1442,13 +1448,14 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
|
||||
# Member with both fields = true
|
||||
{:ok, member_both_true} =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "BothTrue",
|
||||
last_name: "Member",
|
||||
email: "bothtrue.member.#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "BothTrue",
|
||||
last_name: "Member",
|
||||
email: "bothtrue.member.#{System.unique_integer([:positive])}@example.com"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, _cfv1} =
|
||||
Mv.Membership.CustomFieldValue
|
||||
|
|
@ -1472,13 +1479,14 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
|
||||
# Member with field1 = true, field2 = false
|
||||
{:ok, member_mixed} =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Mixed",
|
||||
last_name: "Member",
|
||||
email: "mixed.member.#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Mixed",
|
||||
last_name: "Member",
|
||||
email: "mixed.member.#{System.unique_integer([:positive])}@example.com"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, _cfv3} =
|
||||
Mv.Membership.CustomFieldValue
|
||||
|
|
@ -1507,7 +1515,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
to_string(boolean_field2.id) => true
|
||||
}
|
||||
|
||||
all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
|
||||
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor)
|
||||
|
||||
result =
|
||||
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
|
||||
|
|
@ -1538,7 +1546,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
|
||||
members = [member]
|
||||
filters = %{fake_id => true}
|
||||
all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
|
||||
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor)
|
||||
|
||||
result =
|
||||
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
|
||||
|
|
@ -1575,13 +1583,14 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
)
|
||||
|
||||
{:ok, _member_without_value} =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "NoValue",
|
||||
last_name: "Member",
|
||||
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "NoValue",
|
||||
last_name: "Member",
|
||||
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
# Test true filter
|
||||
{:ok, _view, html_true} =
|
||||
|
|
@ -1610,14 +1619,15 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
|
||||
# Member with true boolean value and paid status
|
||||
{:ok, member_paid_true} =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "PaidTrue",
|
||||
last_name: "Member",
|
||||
email: "paidtrue.member.#{System.unique_integer([:positive])}@example.com",
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "PaidTrue",
|
||||
last_name: "Member",
|
||||
email: "paidtrue.member.#{System.unique_integer([:positive])}@example.com",
|
||||
membership_fee_type_id: fee_type.id
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, _cfv} =
|
||||
Mv.Membership.CustomFieldValue
|
||||
|
|
@ -1637,14 +1647,15 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
|
||||
# Member with true boolean value but unpaid status
|
||||
{:ok, member_unpaid_true} =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "UnpaidTrue",
|
||||
last_name: "Member",
|
||||
email: "unpaidtrue.member.#{System.unique_integer([:positive])}@example.com",
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "UnpaidTrue",
|
||||
last_name: "Member",
|
||||
email: "unpaidtrue.member.#{System.unique_integer([:positive])}@example.com",
|
||||
membership_fee_type_id: fee_type.id
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, _cfv2} =
|
||||
Mv.Membership.CustomFieldValue
|
||||
|
|
@ -1725,13 +1736,14 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
)
|
||||
|
||||
{:ok, _member_without_value} =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "NoValue",
|
||||
last_name: "Member",
|
||||
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "NoValue",
|
||||
last_name: "Member",
|
||||
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
# Test that filter works even though field is not visible in overview
|
||||
{:ok, _view, html_true} =
|
||||
|
|
@ -1775,6 +1787,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
assert Enum.any?(boolean_fields_after, &(&1.name == "Newly Added Field"))
|
||||
end
|
||||
|
||||
@tag :slow
|
||||
test "boolean filter performance with 150 members", %{conn: conn} do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
|
|||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
|
||||
|
|
@ -40,10 +39,8 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
|
|||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||
|> Ash.create!(actor: system_actor)
|
||||
{:ok, member} = Mv.Membership.create_member(attrs, actor: system_actor)
|
||||
member
|
||||
end
|
||||
|
||||
describe "end-to-end workflows" do
|
||||
|
|
@ -152,7 +149,7 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
|
|||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
member =
|
||||
Member
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.filter(email == ^form_data["member[email]"])
|
||||
|> Ash.read_one!(actor: system_actor)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
|
||||
|
|
@ -40,10 +39,8 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||
|> Ash.create!(actor: system_actor)
|
||||
{:ok, member} = Mv.Membership.create_member(attrs, actor: system_actor)
|
||||
member
|
||||
end
|
||||
|
||||
# Helper to create a cycle
|
||||
|
|
|
|||
|
|
@ -18,20 +18,17 @@ defmodule MvWeb.MemberLive.ShowTest do
|
|||
require Ash.Query
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
||||
alias Mv.Membership.{CustomField, CustomFieldValue}
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
# Create test member
|
||||
{:ok, member} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Alice",
|
||||
last_name: "Anderson",
|
||||
email: "alice@example.com"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
%{member: member, actor: system_actor}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -10,29 +10,26 @@ defmodule MvWeb.UserLive.FormTest do
|
|||
end
|
||||
|
||||
describe "new user form - display" do
|
||||
test "shows correct form elements", %{conn: conn} do
|
||||
@tag :ui
|
||||
test "shows correct form elements and password field toggling", %{conn: conn} do
|
||||
{:ok, view, html} = setup_live_view(conn, "/users/new")
|
||||
|
||||
# Basic form elements
|
||||
assert html =~ "New User"
|
||||
assert html =~ "Email"
|
||||
assert html =~ "Set Password"
|
||||
assert has_element?(view, "form#user-form[phx-submit='save']")
|
||||
assert has_element?(view, "input[name='user[email]']")
|
||||
assert has_element?(view, "input[type='checkbox'][name='set_password']")
|
||||
end
|
||||
|
||||
test "hides password fields initially", %{conn: conn} do
|
||||
{:ok, view, _html} = setup_live_view(conn, "/users/new")
|
||||
|
||||
# Password fields should be hidden initially
|
||||
refute has_element?(view, "input[name='user[password]']")
|
||||
refute has_element?(view, "input[name='user[password_confirmation]']")
|
||||
end
|
||||
|
||||
test "shows password fields when checkbox toggled", %{conn: conn} do
|
||||
{:ok, view, _html} = setup_live_view(conn, "/users/new")
|
||||
|
||||
# Toggle password fields
|
||||
view |> element("input[name='set_password']") |> render_click()
|
||||
|
||||
# Password fields should now be visible
|
||||
assert has_element?(view, "input[name='user[password]']")
|
||||
assert has_element?(view, "input[name='user[password_confirmation]']")
|
||||
assert render(view) =~ "Password requirements"
|
||||
|
|
@ -115,7 +112,7 @@ defmodule MvWeb.UserLive.FormTest do
|
|||
)
|
||||
|
||||
assert user.hashed_password != nil
|
||||
assert String.starts_with?(user.hashed_password, "$2b$")
|
||||
refute is_nil(user.hashed_password)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -153,22 +150,21 @@ defmodule MvWeb.UserLive.FormTest do
|
|||
end
|
||||
|
||||
describe "edit user form - display" do
|
||||
test "shows correct form elements for existing user", %{conn: conn} do
|
||||
@tag :ui
|
||||
test "shows correct form elements and admin password fields", %{conn: conn} do
|
||||
user = create_test_user(%{email: "editme@example.com"})
|
||||
{:ok, view, html} = setup_live_view(conn, "/users/#{user.id}/edit")
|
||||
|
||||
# Basic form elements
|
||||
assert html =~ "Edit User"
|
||||
assert html =~ "Change Password"
|
||||
assert has_element?(view, "input[name='user[email]'][value='editme@example.com']")
|
||||
assert html =~ "Check 'Change Password' above to set a new password for this user"
|
||||
end
|
||||
|
||||
test "shows admin password fields when enabled", %{conn: conn} do
|
||||
user = create_test_user(%{email: "editme@example.com"})
|
||||
{:ok, view, _html} = setup_live_view(conn, "/users/#{user.id}/edit")
|
||||
|
||||
# Toggle admin password fields
|
||||
view |> element("input[name='set_password']") |> render_click()
|
||||
|
||||
# Admin password fields should be visible (no confirmation field for admin)
|
||||
assert has_element?(view, "input[name='user[password]']")
|
||||
refute has_element?(view, "input[name='user[password_confirmation]']")
|
||||
assert render(view) =~ "Admin Note"
|
||||
|
|
@ -214,7 +210,8 @@ defmodule MvWeb.UserLive.FormTest do
|
|||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
updated_user = Ash.reload!(user, domain: Mv.Accounts, actor: system_actor)
|
||||
assert updated_user.hashed_password != original_password
|
||||
assert String.starts_with?(updated_user.hashed_password, "$2b$")
|
||||
assert not is_nil(updated_user.hashed_password)
|
||||
assert updated_user.hashed_password != ""
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -258,39 +255,6 @@ defmodule MvWeb.UserLive.FormTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "internationalization" do
|
||||
test "shows German labels", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn, %{email: "admin_de@example.com"})
|
||||
conn = Plug.Test.init_test_session(conn, locale: "de")
|
||||
{:ok, _view, html} = live(conn, "/users/new")
|
||||
|
||||
assert html =~ "Neue*r Benutzer*in"
|
||||
assert html =~ "E-Mail"
|
||||
assert html =~ "Passwort setzen"
|
||||
end
|
||||
|
||||
test "shows English labels", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn, %{email: "admin_en@example.com"})
|
||||
Gettext.put_locale(MvWeb.Gettext, "en")
|
||||
{:ok, _view, html} = live(conn, "/users/new")
|
||||
|
||||
assert html =~ "New User"
|
||||
assert html =~ "Email"
|
||||
assert html =~ "Set Password"
|
||||
end
|
||||
|
||||
test "shows different labels for edit vs new", %{conn: conn} do
|
||||
user = create_test_user(%{email: "test@example.com"})
|
||||
conn = conn_with_oidc_user(conn, %{email: "admin@example.com"})
|
||||
|
||||
{:ok, _view, new_html} = live(conn, "/users/new")
|
||||
{:ok, _view, edit_html} = live(conn, "/users/#{user.id}/edit")
|
||||
|
||||
assert new_html =~ "Set Password"
|
||||
assert edit_html =~ "Change Password"
|
||||
end
|
||||
end
|
||||
|
||||
describe "member linking - display" do
|
||||
test "shows linked member with unlink button when user has member", %{conn: conn} do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
|
@ -421,6 +385,39 @@ defmodule MvWeb.UserLive.FormTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "internationalization" do
|
||||
@tag :ui
|
||||
test "shows translated labels in different locales", %{conn: conn} do
|
||||
# Test German labels
|
||||
conn = conn_with_oidc_user(conn, %{email: "admin_de@example.com"})
|
||||
conn = Plug.Test.init_test_session(conn, locale: "de")
|
||||
{:ok, _view, html_de} = live(conn, "/users/new")
|
||||
|
||||
assert html_de =~ "Neue*r Benutzer*in"
|
||||
assert html_de =~ "E-Mail"
|
||||
assert html_de =~ "Passwort setzen"
|
||||
|
||||
# Test English labels
|
||||
conn = conn_with_oidc_user(conn, %{email: "admin_en@example.com"})
|
||||
conn = Plug.Test.init_test_session(conn, locale: "en")
|
||||
{:ok, _view, html_en} = live(conn, "/users/new")
|
||||
|
||||
assert html_en =~ "New User"
|
||||
assert html_en =~ "Email"
|
||||
assert html_en =~ "Set Password"
|
||||
|
||||
# Test different labels for edit vs new
|
||||
user = create_test_user(%{email: "test@example.com"})
|
||||
conn = conn_with_oidc_user(conn, %{email: "admin@example.com"})
|
||||
|
||||
{:ok, _view, new_html} = live(conn, "/users/new")
|
||||
{:ok, _view, edit_html} = live(conn, "/users/#{user.id}/edit")
|
||||
|
||||
assert new_html =~ "Set Password"
|
||||
assert edit_html =~ "Change Password"
|
||||
end
|
||||
end
|
||||
|
||||
describe "system actor user" do
|
||||
test "redirects to user list when editing system actor user", %{conn: conn} do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
|
|
|||
|
|
@ -3,46 +3,38 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
import Phoenix.LiveViewTest
|
||||
|
||||
describe "basic functionality" do
|
||||
test "shows translated title in German", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = Plug.Test.init_test_session(conn, locale: "de")
|
||||
{:ok, _view, html} = live(conn, "/users")
|
||||
assert html =~ "Benutzer*innen auflisten"
|
||||
end
|
||||
|
||||
test "shows translated title in English", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
Gettext.put_locale(MvWeb.Gettext, "en")
|
||||
{:ok, _view, html} = live(conn, "/users")
|
||||
assert html =~ "Listing Users"
|
||||
end
|
||||
|
||||
test "shows New User button", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/users")
|
||||
assert html =~ "New User"
|
||||
end
|
||||
|
||||
test "displays users in a table", %{conn: conn} do
|
||||
@tag :ui
|
||||
test "displays users in a table with basic UI elements", %{conn: conn} do
|
||||
# Create test users
|
||||
_user1 = create_test_user(%{email: "alice@example.com", oidc_id: "alice123"})
|
||||
user1 = create_test_user(%{email: "alice@example.com", oidc_id: "alice123"})
|
||||
_user2 = create_test_user(%{email: "bob@example.com", oidc_id: "bob456"})
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/users")
|
||||
|
||||
# Basic table rendering
|
||||
assert html =~ "alice@example.com"
|
||||
assert html =~ "bob@example.com"
|
||||
end
|
||||
|
||||
test "shows correct action links", %{conn: conn} do
|
||||
user = create_test_user(%{email: "test@example.com"})
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/users")
|
||||
|
||||
# UI elements: New User button, action links
|
||||
assert html =~ "New User"
|
||||
assert html =~ "Edit"
|
||||
assert html =~ "Delete"
|
||||
assert html =~ ~r/href="[^"]*\/users\/#{user.id}\/edit"/
|
||||
assert html =~ ~r/href="[^"]*\/users\/#{user1.id}\/edit"/
|
||||
end
|
||||
|
||||
@tag :ui
|
||||
test "shows translated titles in different locales", %{conn: conn} do
|
||||
# Test German translation
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = Plug.Test.init_test_session(conn, locale: "de")
|
||||
{:ok, _view, html_de} = live(conn, "/users")
|
||||
assert html_de =~ "Benutzer*innen auflisten"
|
||||
|
||||
# Test English translation
|
||||
conn = Plug.Test.init_test_session(conn, locale: "en")
|
||||
{:ok, _view, html_en} = live(conn, "/users")
|
||||
assert html_en =~ "Listing Users"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -56,6 +48,7 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
%{users: [user_a, user_z, user_m]}
|
||||
end
|
||||
|
||||
@tag :slow
|
||||
test "initially sorts by email ascending", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/users")
|
||||
|
|
@ -73,6 +66,7 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
assert mike_pos < zulu_pos, "mike@example.com should appear before zulu@example.com"
|
||||
end
|
||||
|
||||
@tag :slow
|
||||
test "can sort email descending by clicking sort button", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/users")
|
||||
|
|
@ -96,28 +90,8 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
"mike@example.com should appear before alpha@example.com when sorted desc"
|
||||
end
|
||||
|
||||
test "toggles back to ascending when clicking sort button twice", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/users")
|
||||
|
||||
# Click twice to toggle: asc -> desc -> asc
|
||||
view |> element("button[phx-value-field='email']") |> render_click()
|
||||
html = view |> element("button[phx-value-field='email']") |> render_click()
|
||||
|
||||
# Should be back to ascending
|
||||
assert html =~ "hero-chevron-up"
|
||||
assert html =~ ~s(aria-sort="ascending")
|
||||
|
||||
# Should be back to original ascending order
|
||||
alpha_pos = html |> :binary.match("alpha@example.com") |> elem(0)
|
||||
mike_pos = html |> :binary.match("mike@example.com") |> elem(0)
|
||||
zulu_pos = html |> :binary.match("zulu@example.com") |> elem(0)
|
||||
|
||||
assert alpha_pos < mike_pos, "Should be back to ascending: alpha before mike"
|
||||
assert mike_pos < zulu_pos, "Should be back to ascending: mike before zulu"
|
||||
end
|
||||
|
||||
test "shows sort direction icons", %{conn: conn} do
|
||||
@tag :ui
|
||||
test "toggles sort direction and shows correct icons", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/users")
|
||||
|
||||
|
|
@ -129,6 +103,19 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
view |> element("button[phx-value-field='email']") |> render_click()
|
||||
html = render(view)
|
||||
assert html =~ "hero-chevron-down"
|
||||
|
||||
# Click again to toggle back to ascending
|
||||
html = view |> element("button[phx-value-field='email']") |> render_click()
|
||||
assert html =~ "hero-chevron-up"
|
||||
assert html =~ ~s(aria-sort="ascending")
|
||||
|
||||
# Should be back to original ascending order
|
||||
alpha_pos = html |> :binary.match("alpha@example.com") |> elem(0)
|
||||
mike_pos = html |> :binary.match("mike@example.com") |> elem(0)
|
||||
zulu_pos = html |> :binary.match("zulu@example.com") |> elem(0)
|
||||
|
||||
assert alpha_pos < mike_pos, "Should be back to ascending: alpha before mike"
|
||||
assert mike_pos < zulu_pos, "Should be back to ascending: mike before zulu"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -139,24 +126,23 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
%{users: [user1, user2]}
|
||||
end
|
||||
|
||||
test "shows select all checkbox", %{conn: conn} do
|
||||
@tag :ui
|
||||
test "shows checkbox UI elements", %{conn: conn, users: [user1, user2]} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/users")
|
||||
|
||||
# Check select all checkbox exists
|
||||
assert html =~ ~s(name="select_all")
|
||||
assert html =~ ~s(phx-click="select_all")
|
||||
end
|
||||
|
||||
test "shows individual user checkboxes", %{conn: conn, users: [user1, user2]} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/users")
|
||||
|
||||
# Check individual user checkboxes exist
|
||||
assert html =~ ~s(name="#{user1.id}")
|
||||
assert html =~ ~s(name="#{user2.id}")
|
||||
assert html =~ ~s(phx-click="select_user")
|
||||
end
|
||||
|
||||
test "can select individual users", %{conn: conn, users: [user1, user2]} do
|
||||
@tag :ui
|
||||
test "can select and deselect individual users", %{conn: conn, users: [user1, user2]} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/users")
|
||||
|
||||
|
|
@ -164,45 +150,31 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
assert view |> element("input[type='checkbox'][name='#{user1.id}']") |> has_element?()
|
||||
assert view |> element("input[type='checkbox'][name='#{user2.id}']") |> has_element?()
|
||||
|
||||
# Initially, select_all should not be checked (since no individual items are selected)
|
||||
refute view
|
||||
|> element("input[type='checkbox'][name='select_all'][checked]")
|
||||
|> has_element?()
|
||||
|
||||
# Select first user checkbox
|
||||
html = view |> element("input[type='checkbox'][name='#{user1.id}']") |> render_click()
|
||||
assert html =~ "Email"
|
||||
assert html =~ to_string(user1.email)
|
||||
|
||||
# The select_all checkbox should still not be checked (not all users selected)
|
||||
refute view
|
||||
|> element("input[type='checkbox'][name='select_all'][checked]")
|
||||
|> has_element?()
|
||||
|
||||
# Page should still function normally
|
||||
assert html =~ "Email"
|
||||
assert html =~ to_string(user1.email)
|
||||
end
|
||||
|
||||
test "can deselect individual users", %{conn: conn, users: [user1, _user2]} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/users")
|
||||
|
||||
# Select user first
|
||||
view |> element("input[type='checkbox'][name='#{user1.id}']") |> render_click()
|
||||
|
||||
# Then deselect user
|
||||
# Deselect user
|
||||
html = view |> element("input[type='checkbox'][name='#{user1.id}']") |> render_click()
|
||||
assert html =~ "Email"
|
||||
|
||||
# Select all should not be checked after deselecting individual user
|
||||
refute view
|
||||
|> element("input[type='checkbox'][name='select_all'][checked]")
|
||||
|> has_element?()
|
||||
|
||||
# Page should still function normally
|
||||
assert html =~ "Email"
|
||||
assert html =~ to_string(user1.email)
|
||||
end
|
||||
|
||||
test "select all functionality selects all users", %{conn: conn, users: [user1, user2]} do
|
||||
@tag :ui
|
||||
test "select all and deselect all functionality", %{conn: conn, users: [user1, user2]} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/users")
|
||||
|
||||
|
|
@ -227,23 +199,9 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
|> element("input[type='checkbox'][name='select_all'][checked]")
|
||||
|> has_element?()
|
||||
|
||||
# Page should still function normally and show all users
|
||||
assert html =~ "Email"
|
||||
assert html =~ to_string(user1.email)
|
||||
assert html =~ to_string(user2.email)
|
||||
end
|
||||
|
||||
test "deselect all functionality deselects all users", %{conn: conn, users: [user1, user2]} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/users")
|
||||
|
||||
# Select all first
|
||||
view |> element("input[type='checkbox'][name='select_all']") |> render_click()
|
||||
|
||||
# Verify that select_all is checked
|
||||
assert view
|
||||
|> element("input[type='checkbox'][name='select_all'][checked]")
|
||||
|> has_element?()
|
||||
|
||||
# Then deselect all
|
||||
html = view |> element("input[type='checkbox'][name='select_all']") |> render_click()
|
||||
|
|
@ -261,40 +219,47 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
|> element("input[type='checkbox'][name='#{user2.id}'][checked]")
|
||||
|> has_element?()
|
||||
|
||||
# Page should still function normally
|
||||
assert html =~ "Email"
|
||||
assert html =~ to_string(user1.email)
|
||||
assert html =~ to_string(user2.email)
|
||||
end
|
||||
|
||||
@tag :slow
|
||||
test "select all automatically checks when all individual users are selected", %{
|
||||
conn: conn,
|
||||
users: [user1, user2]
|
||||
users: [_user1, _user2]
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/users")
|
||||
{:ok, view, html} = live(conn, "/users")
|
||||
|
||||
# Initially nothing should be checked
|
||||
refute view
|
||||
|> element("input[type='checkbox'][name='select_all'][checked]")
|
||||
|> has_element?()
|
||||
# Get all user IDs from the rendered HTML by finding all checkboxes with phx-click="select_user"
|
||||
# Extract user IDs from the HTML (they appear as name attributes on checkboxes)
|
||||
user_ids =
|
||||
html
|
||||
|> String.split("phx-click=\"select_user\"")
|
||||
|> Enum.flat_map(fn part ->
|
||||
case Regex.run(~r/name="([^"]+)"[^>]*phx-value-id/, part) do
|
||||
[_, user_id] -> [user_id]
|
||||
_ -> []
|
||||
end
|
||||
end)
|
||||
|> Enum.uniq()
|
||||
|
||||
# Select first user
|
||||
view |> element("input[type='checkbox'][name='#{user1.id}']") |> render_click()
|
||||
# Select all should still not be checked (only 1 of 2+ users selected)
|
||||
refute view
|
||||
|> element("input[type='checkbox'][name='select_all'][checked]")
|
||||
|> has_element?()
|
||||
# Skip if no users found (shouldn't happen, but be safe)
|
||||
if user_ids != [] do
|
||||
# Initially nothing should be checked
|
||||
refute view
|
||||
|> element("input[type='checkbox'][name='select_all'][checked]")
|
||||
|> has_element?()
|
||||
|
||||
# Select second user
|
||||
html = view |> element("input[type='checkbox'][name='#{user2.id}']") |> render_click()
|
||||
# Select all users one by one
|
||||
Enum.each(user_ids, fn user_id ->
|
||||
view |> element("input[type='checkbox'][name='#{user_id}']") |> render_click()
|
||||
end)
|
||||
|
||||
# Now select all should be automatically checked (all individual users are selected)
|
||||
# Note: This test might need adjustment based on actual implementation
|
||||
# The logic depends on whether authenticated user is included in the count
|
||||
assert html =~ "Email"
|
||||
assert html =~ to_string(user1.email)
|
||||
assert html =~ to_string(user2.email)
|
||||
# Now select all should be automatically checked (all individual users are selected)
|
||||
assert view
|
||||
|> element("input[type='checkbox'][name='select_all'][checked]")
|
||||
|> has_element?()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -307,11 +272,12 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
# Confirm user is displayed
|
||||
assert render(view) =~ "delete-me@example.com"
|
||||
|
||||
# Click the first delete button to test the functionality
|
||||
# Click the delete button (phx-click="delete" event)
|
||||
view |> element("tbody tr:first-child a[data-confirm]") |> render_click()
|
||||
|
||||
# The page should still render (basic functionality test)
|
||||
# Verify user was actually deleted (should not appear in HTML anymore)
|
||||
html = render(view)
|
||||
refute html =~ "delete-me@example.com"
|
||||
# Table header should still be there
|
||||
assert html =~ "Email"
|
||||
end
|
||||
|
|
@ -327,52 +293,39 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
end
|
||||
|
||||
describe "navigation" do
|
||||
test "clicking on user row navigates to user show page", %{conn: conn} do
|
||||
@tag :ui
|
||||
test "navigation links point to correct pages", %{conn: conn} do
|
||||
user = create_test_user(%{email: "navigate@example.com"})
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/users")
|
||||
{:ok, view, html} = live(conn, "/users")
|
||||
|
||||
# This test would need to check row click behavior
|
||||
# The actual navigation would happen via JavaScript
|
||||
html = render(view)
|
||||
# Check that user row contains link to show page
|
||||
assert html =~ ~s(/users/#{user.id})
|
||||
end
|
||||
|
||||
test "edit link points to correct edit page", %{conn: conn} do
|
||||
user = create_test_user(%{email: "edit-me@example.com"})
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/users")
|
||||
|
||||
# Check edit link points to correct edit page
|
||||
assert html =~ ~s(/users/#{user.id}/edit)
|
||||
end
|
||||
|
||||
test "new user button points to correct new page", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/users")
|
||||
|
||||
# Check new user button points to correct new page
|
||||
assert html =~ ~s(/users/new)
|
||||
end
|
||||
end
|
||||
|
||||
describe "translations" do
|
||||
test "shows German translations for selection", %{conn: conn} do
|
||||
@tag :ui
|
||||
test "shows translations for selection in different locales", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
||||
# Test German translations
|
||||
conn = Plug.Test.init_test_session(conn, locale: "de")
|
||||
{:ok, _view, html} = live(conn, "/users")
|
||||
{:ok, _view, html_de} = live(conn, "/users")
|
||||
assert html_de =~ "Alle Benutzer*innen auswählen"
|
||||
assert html_de =~ "Benutzer*in auswählen"
|
||||
|
||||
assert html =~ "Alle Benutzer*innen auswählen"
|
||||
assert html =~ "Benutzer*in auswählen"
|
||||
end
|
||||
|
||||
test "shows English translations for selection", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
# Test English translations
|
||||
Gettext.put_locale(MvWeb.Gettext, "en")
|
||||
{:ok, _view, html} = live(conn, "/users")
|
||||
|
||||
# Note: English translations might be empty strings by default
|
||||
# This test would verify the structure is there
|
||||
# Checking that aria-label attributes exist
|
||||
assert html =~ ~s(aria-label=)
|
||||
{:ok, _view, html_en} = live(conn, "/users")
|
||||
# Check that aria-label attributes exist (structure is there)
|
||||
assert html_en =~ ~s(aria-label=)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -427,6 +380,7 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
end
|
||||
|
||||
describe "member linking display" do
|
||||
@tag :slow
|
||||
test "displays linked member name in user list", %{conn: conn} do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,44 +1,81 @@
|
|||
defmodule Mv.SeedsTest do
|
||||
@moduledoc """
|
||||
Minimal test suite for database seeds.
|
||||
|
||||
This test suite focuses on critical deployment requirements:
|
||||
- Seeds run without errors (smoke test)
|
||||
- Seeds are idempotent (can be run multiple times)
|
||||
- Critical bootstrap invariants are met (Admin user, system roles)
|
||||
|
||||
## Removed Tests
|
||||
|
||||
The following tests were removed to reduce test execution time.
|
||||
Their functionality is covered by domain-specific tests:
|
||||
|
||||
- Member/fee type distribution tests → covered by `membership_fees/*_test.exs`
|
||||
- Cycle status variation tests → covered by `membership_fees/cycle_generator_test.exs`
|
||||
- Detailed role configuration tests → covered by `authorization/*_test.exs`
|
||||
- Permission set validation → covered by `authorization/permission_sets_test.exs`
|
||||
|
||||
See `docs/test-optimization-seeds.md` for detailed rationale and coverage mapping.
|
||||
"""
|
||||
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
require Ash.Query
|
||||
|
||||
# Module attribute to track if seeds have been run
|
||||
# This allows us to run seeds once per test module while respecting sandbox isolation
|
||||
@seeds_run_key :seeds_test_run
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
# Run seeds once per test process (due to sandbox isolation, each test runs in its own process)
|
||||
# Process.get/put ensures seeds are only executed once per test process, not once per test
|
||||
# Note: With async: false and sandbox isolation, this effectively runs seeds once per test,
|
||||
# but the guard prevents multiple executions within the same test process if setup is called multiple times
|
||||
unless Process.get(@seeds_run_key) do
|
||||
Code.eval_file("priv/repo/seeds.exs")
|
||||
Process.put(@seeds_run_key, true)
|
||||
end
|
||||
|
||||
%{actor: system_actor}
|
||||
end
|
||||
|
||||
describe "Seeds script" do
|
||||
test "runs successfully without errors", %{actor: actor} do
|
||||
# Run the seeds script - should not raise any errors
|
||||
assert Code.eval_file("priv/repo/seeds.exs")
|
||||
|
||||
# Basic smoke test: ensure some data was created
|
||||
@tag :slow
|
||||
test "runs successfully and creates basic data", %{actor: actor} do
|
||||
# Smoke test: verify seeds created essential data structures
|
||||
{:ok, users} = Ash.read(Mv.Accounts.User, actor: actor)
|
||||
{:ok, members} = Ash.read(Mv.Membership.Member, actor: actor)
|
||||
{:ok, custom_fields} = Ash.read(Mv.Membership.CustomField, actor: actor)
|
||||
{:ok, roles} = Ash.read(Mv.Authorization.Role, domain: Mv.Authorization, authorize?: false)
|
||||
|
||||
assert not Enum.empty?(users), "Seeds should create at least one user"
|
||||
assert not Enum.empty?(members), "Seeds should create at least one member"
|
||||
assert not Enum.empty?(custom_fields), "Seeds should create at least one custom field"
|
||||
assert length(roles) >= 5, "Seeds should create at least 5 authorization roles"
|
||||
end
|
||||
|
||||
test "can be run multiple times (idempotent)", %{actor: actor} do
|
||||
# Run seeds first time
|
||||
assert Code.eval_file("priv/repo/seeds.exs")
|
||||
|
||||
# Count records
|
||||
@tag :slow
|
||||
test "is idempotent when run multiple times", %{actor: actor} do
|
||||
# Seeds already run once in setup - count current state
|
||||
{:ok, users_count_1} = Ash.read(Mv.Accounts.User, actor: actor)
|
||||
{:ok, members_count_1} = Ash.read(Mv.Membership.Member, actor: actor)
|
||||
{:ok, custom_fields_count_1} = Ash.read(Mv.Membership.CustomField, actor: actor)
|
||||
|
||||
# Run seeds second time - should not raise errors
|
||||
{:ok, roles_count_1} =
|
||||
Ash.read(Mv.Authorization.Role, domain: Mv.Authorization, authorize?: false)
|
||||
|
||||
# Run seeds again - should not raise errors and should be idempotent
|
||||
assert Code.eval_file("priv/repo/seeds.exs")
|
||||
|
||||
# Count records again - should be the same (upsert, not duplicate)
|
||||
{:ok, users_count_2} = Ash.read(Mv.Accounts.User, actor: actor)
|
||||
{:ok, members_count_2} = Ash.read(Mv.Membership.Member, actor: actor)
|
||||
{:ok, custom_fields_count_2} = Ash.read(Mv.Membership.CustomField, actor: actor)
|
||||
|
||||
{:ok, roles_count_2} =
|
||||
Ash.read(Mv.Authorization.Role, domain: Mv.Authorization, authorize?: false)
|
||||
|
||||
assert length(users_count_1) == length(users_count_2),
|
||||
"Users count should remain same after re-running seeds"
|
||||
|
|
@ -46,215 +83,45 @@ defmodule Mv.SeedsTest do
|
|||
assert length(members_count_1) == length(members_count_2),
|
||||
"Members count should remain same after re-running seeds"
|
||||
|
||||
assert length(custom_fields_count_1) == length(custom_fields_count_2),
|
||||
"CustomFields count should remain same after re-running seeds"
|
||||
end
|
||||
|
||||
test "at least one member has no membership fee type assigned", %{actor: actor} do
|
||||
# Run the seeds script
|
||||
assert Code.eval_file("priv/repo/seeds.exs")
|
||||
|
||||
# Get all members
|
||||
{:ok, members} = Ash.read(Mv.Membership.Member, actor: actor)
|
||||
|
||||
# At least one member should have no membership_fee_type_id
|
||||
members_without_fee_type =
|
||||
Enum.filter(members, fn member -> member.membership_fee_type_id == nil end)
|
||||
|
||||
assert not Enum.empty?(members_without_fee_type),
|
||||
"At least one member should have no membership fee type assigned"
|
||||
end
|
||||
|
||||
test "each membership fee type has at least one member", %{actor: actor} do
|
||||
# Run the seeds script
|
||||
assert Code.eval_file("priv/repo/seeds.exs")
|
||||
|
||||
# Get all fee types and members
|
||||
{:ok, fee_types} = Ash.read(Mv.MembershipFees.MembershipFeeType, actor: actor)
|
||||
{:ok, members} = Ash.read(Mv.Membership.Member, actor: actor)
|
||||
|
||||
# Group members by fee type (excluding nil)
|
||||
members_by_fee_type =
|
||||
members
|
||||
|> Enum.filter(&(&1.membership_fee_type_id != nil))
|
||||
|> Enum.group_by(& &1.membership_fee_type_id)
|
||||
|
||||
# Each fee type should have at least one member
|
||||
Enum.each(fee_types, fn fee_type ->
|
||||
members_for_type = Map.get(members_by_fee_type, fee_type.id, [])
|
||||
|
||||
assert not Enum.empty?(members_for_type),
|
||||
"Membership fee type #{fee_type.name} should have at least one member assigned"
|
||||
end)
|
||||
end
|
||||
|
||||
test "members with fee types have cycles with various statuses", %{actor: actor} do
|
||||
# Run the seeds script
|
||||
assert Code.eval_file("priv/repo/seeds.exs")
|
||||
|
||||
# Get all members with fee types
|
||||
{:ok, members} = Ash.read(Mv.Membership.Member, actor: actor)
|
||||
|
||||
members_with_fee_types =
|
||||
members
|
||||
|> Enum.filter(&(&1.membership_fee_type_id != nil))
|
||||
|
||||
# At least one member should have cycles
|
||||
assert not Enum.empty?(members_with_fee_types),
|
||||
"At least one member should have a membership fee type"
|
||||
|
||||
# Check that cycles exist and have various statuses
|
||||
all_cycle_statuses =
|
||||
members_with_fee_types
|
||||
|> Enum.flat_map(fn member ->
|
||||
Mv.MembershipFees.MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id)
|
||||
|> Ash.read!(actor: actor)
|
||||
end)
|
||||
|> Enum.map(& &1.status)
|
||||
|
||||
# At least one cycle should be paid
|
||||
assert :paid in all_cycle_statuses, "At least one cycle should be paid"
|
||||
# At least one cycle should be unpaid
|
||||
assert :unpaid in all_cycle_statuses, "At least one cycle should be unpaid"
|
||||
# At least one cycle should be suspended
|
||||
assert :suspended in all_cycle_statuses, "At least one cycle should be suspended"
|
||||
assert length(roles_count_1) == length(roles_count_2),
|
||||
"Roles count should remain same after re-running seeds"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Authorization roles (from seeds)" do
|
||||
test "creates all 5 authorization roles with correct permission sets" do
|
||||
# Run seeds once for this test
|
||||
Code.eval_file("priv/repo/seeds.exs")
|
||||
{:ok, roles} = Ash.read(Mv.Authorization.Role, domain: Mv.Authorization, authorize?: false)
|
||||
describe "Critical bootstrap invariants" do
|
||||
test "Admin user has Admin role and can authenticate", %{actor: _actor} do
|
||||
# Critical: Without this, initial system setup fails
|
||||
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
||||
|
||||
assert length(roles) >= 5, "Should have at least 5 roles"
|
||||
{:ok, admin_user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Query.filter(email == ^admin_email)
|
||||
|> Ash.read_one(domain: Mv.Accounts, authorize?: false)
|
||||
|
||||
# Check each role
|
||||
role_configs = [
|
||||
{"Mitglied", "own_data", true},
|
||||
{"Vorstand", "read_only", false},
|
||||
{"Kassenwart", "normal_user", false},
|
||||
{"Buchhaltung", "read_only", false},
|
||||
{"Admin", "admin", false}
|
||||
]
|
||||
assert admin_user != nil, "Admin user must exist after seeds run"
|
||||
|
||||
Enum.each(role_configs, fn {name, perm_set, is_system} ->
|
||||
role = Enum.find(roles, &(&1.name == name))
|
||||
assert role, "Role #{name} should exist"
|
||||
assert role.permission_set_name == perm_set
|
||||
assert role.is_system_role == is_system
|
||||
end)
|
||||
{:ok, admin_user_with_role} =
|
||||
Ash.load(admin_user, :role, domain: Mv.Accounts, authorize?: false)
|
||||
|
||||
assert admin_user_with_role.role != nil, "Admin user must have a role assigned"
|
||||
assert admin_user_with_role.role.name == "Admin", "Admin user must have Admin role"
|
||||
|
||||
assert admin_user_with_role.role.permission_set_name == "admin",
|
||||
"Admin role must have admin permission set"
|
||||
end
|
||||
|
||||
test "Mitglied role is marked as system role" do
|
||||
Code.eval_file("priv/repo/seeds.exs")
|
||||
|
||||
test "Mitglied system role exists and is correctly configured" do
|
||||
# Critical: Default role for new users - system breaks without this
|
||||
{:ok, mitglied} =
|
||||
Mv.Authorization.Role
|
||||
|> Ash.Query.filter(name == "Mitglied")
|
||||
|> Ash.read_one(domain: Mv.Authorization, authorize?: false)
|
||||
|
||||
assert mitglied.is_system_role == true
|
||||
end
|
||||
assert mitglied != nil, "Mitglied role must exist"
|
||||
assert mitglied.is_system_role == true, "Mitglied role must be marked as system role"
|
||||
|
||||
test "all roles have valid permission_set_names" do
|
||||
Code.eval_file("priv/repo/seeds.exs")
|
||||
|
||||
{:ok, roles} = Ash.read(Mv.Authorization.Role, domain: Mv.Authorization, authorize?: false)
|
||||
|
||||
valid_sets =
|
||||
Mv.Authorization.PermissionSets.all_permission_sets()
|
||||
|> Enum.map(&Atom.to_string/1)
|
||||
|
||||
Enum.each(roles, fn role ->
|
||||
assert role.permission_set_name in valid_sets,
|
||||
"Role #{role.name} has invalid permission_set_name: #{role.permission_set_name}"
|
||||
end)
|
||||
end
|
||||
|
||||
test "assigns Admin role to ADMIN_EMAIL user" do
|
||||
Code.eval_file("priv/repo/seeds.exs")
|
||||
|
||||
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
||||
|
||||
{:ok, admin_user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Query.filter(email == ^admin_email)
|
||||
|> Ash.read_one(domain: Mv.Accounts, authorize?: false)
|
||||
|
||||
assert admin_user != nil, "Admin user should exist after seeds run"
|
||||
|
||||
{:ok, admin_user_with_role} =
|
||||
Ash.load(admin_user, :role, domain: Mv.Accounts, authorize?: false)
|
||||
|
||||
assert admin_user_with_role.role != nil, "Admin user should have a role assigned"
|
||||
assert admin_user_with_role.role.name == "Admin"
|
||||
assert admin_user_with_role.role.permission_set_name == "admin"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Authorization role assignment" do
|
||||
test "does not change role of users who already have a role" do
|
||||
# Seeds once (creates Admin with Admin role)
|
||||
Code.eval_file("priv/repo/seeds.exs")
|
||||
|
||||
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
||||
|
||||
{:ok, admin_user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Query.filter(email == ^admin_email)
|
||||
|> Ash.read_one(domain: Mv.Accounts, authorize?: false)
|
||||
|
||||
assert admin_user != nil, "Admin user should exist after seeds run"
|
||||
|
||||
{:ok, admin_user_with_role} =
|
||||
Ash.load(admin_user, :role, domain: Mv.Accounts, authorize?: false)
|
||||
|
||||
assert admin_user_with_role.role != nil, "Admin user should have a role assigned"
|
||||
original_role_id = admin_user_with_role.role_id
|
||||
assert admin_user_with_role.role.name == "Admin"
|
||||
|
||||
# Seeds again
|
||||
Code.eval_file("priv/repo/seeds.exs")
|
||||
|
||||
# Admin reloaded
|
||||
{:ok, admin_reloaded} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Query.filter(email == ^admin_email)
|
||||
|> Ash.read_one(domain: Mv.Accounts, authorize?: false)
|
||||
|
||||
assert admin_reloaded != nil, "Admin user should still exist after re-running seeds"
|
||||
|
||||
{:ok, admin_reloaded_with_role} =
|
||||
Ash.load(admin_reloaded, :role, domain: Mv.Accounts, authorize?: false)
|
||||
|
||||
assert admin_reloaded_with_role.role != nil,
|
||||
"Admin user should still have a role after re-running seeds"
|
||||
|
||||
assert admin_reloaded_with_role.role_id == original_role_id
|
||||
assert admin_reloaded_with_role.role.name == "Admin"
|
||||
end
|
||||
|
||||
test "role creation is idempotent" do
|
||||
Code.eval_file("priv/repo/seeds.exs")
|
||||
|
||||
{:ok, roles_1} =
|
||||
Ash.read(Mv.Authorization.Role, domain: Mv.Authorization, authorize?: false)
|
||||
|
||||
Code.eval_file("priv/repo/seeds.exs")
|
||||
|
||||
{:ok, roles_2} =
|
||||
Ash.read(Mv.Authorization.Role, domain: Mv.Authorization, authorize?: false)
|
||||
|
||||
assert length(roles_1) == length(roles_2),
|
||||
"Role count should remain same after re-running seeds"
|
||||
|
||||
# Each role should appear exactly once
|
||||
role_names = Enum.map(roles_2, & &1.name)
|
||||
|
||||
assert length(role_names) == length(Enum.uniq(role_names)),
|
||||
"Each role name should appear exactly once"
|
||||
assert mitglied.permission_set_name == "own_data",
|
||||
"Mitglied role must have own_data permission set"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue