Compare commits

..

6 commits

Author SHA1 Message Date
ae6e26e487
Sync user email to member when changing password (admin_set_password)
All checks were successful
continuous-integration/drone/push Build is passing
Add SyncUserEmailToMember change to admin_set_password so email+password
updates in the user form sync the new email to the linked member.
2026-01-27 16:14:06 +01:00
b5b2317d69
Add gettext strings for system actor show/edit redirect messages
Some checks failed
continuous-integration/drone/push Build is failing
German: Dieser Benutzer kann nicht angezeigt/bearbeitet werden.
2026-01-27 16:08:40 +01:00
86c1ab8462
Add tests for system actor protection and hiding
Index: system actor not in list, destroy returns Ash.Error.Invalid. Show/Form:
redirect to /users when viewing or editing system actor user.
2026-01-27 16:08:40 +01:00
56bf411756
Hide system actor from user list and block show/edit
Index: filter out SystemActor.system_user_email() in query. Show/Form:
redirect to /users with flash when viewing or editing system actor user.
Index format_error: handle Ash errors without :message field.
2026-01-27 16:08:40 +01:00
55f322a09b
Prevent deletion of system actor user
Add destroy validation and explicit destroy action (primary, require_atomic? false).
Validation blocks destroy when email == SystemActor.system_user_email().
2026-01-27 16:08:40 +01:00
63377717e4
Ensure system actor user exists via migration
Creates user system@mila.local with Admin role if missing. Idempotent;
guarantees system actor in production without relying on seeds.
2026-01-27 16:08:39 +01:00
25 changed files with 88 additions and 1863 deletions

View file

@ -1515,40 +1515,6 @@ mix test test/membership/member_test.exs:42
### 4.7 Testing Best Practices ### 4.7 Testing Best Practices
**Testing Philosophy: Focus on Business Logic, Not Framework Functionality**
We test our business logic and domain-specific behavior, not core framework features. Framework features (Ash validations, Ecto relationships, etc.) are already tested by their respective libraries.
**What We Test:**
- Business rules and validations specific to our domain
- Custom business logic (slug generation, calculations, etc.)
- Integration between our resources
- Database-level constraints (unique constraints, foreign keys, CASCADE)
- Query performance (N+1 prevention)
**What We Don't Test:**
- Framework core functionality (Ash validations work, Ecto relationships work, etc.)
- Standard CRUD operations without custom logic
- Framework-provided features that are already tested upstream
- Detailed slug generation edge cases (Umlauts, truncation, etc.) if covered by reusable change tests
**Example:**
```elixir
# ✅ GOOD - Tests our business rule
test "slug is immutable (doesn't change when name is updated)" do
{:ok, group} = Membership.create_group(%{name: "Original"}, actor: actor)
original_slug = group.slug
{:ok, updated} = Membership.update_group(group, %{name: "New"}, actor: actor)
assert updated.slug == original_slug # Business rule: slug doesn't change
end
# ❌ AVOID - Tests framework functionality
test "Ash.Changeset validates required fields" do
# This is already tested by Ash framework
end
```
**Descriptive Test Names:** **Descriptive Test Names:**
```elixir ```elixir

View file

@ -49,8 +49,7 @@ This document defines the technical architecture for the Groups feature. It focu
- No parent/child relationships in MVP - No parent/child relationships in MVP
3. **Minimal Attributes (MVP):** 3. **Minimal Attributes (MVP):**
- `name`, `description`, and `slug` in initial version - Only `name` and `description` in initial version
- `slug` is automatically generated from `name` (immutable, URL-friendly)
- Extensible for future attributes (dates, status, etc.) - Extensible for future attributes (dates, status, etc.)
4. **Cascade Deletion:** 4. **Cascade Deletion:**
@ -72,7 +71,7 @@ This document defines the technical architecture for the Groups feature. It focu
**New Resources:** **New Resources:**
- `Group` - Group definitions (name, description, slug) - `Group` - Group definitions (name, description)
- `MemberGroup` - Join table for many-to-many relationship between Members and Groups - `MemberGroup` - Join table for many-to-many relationship between Members and Groups
**Extended Resources:** **Extended Resources:**
@ -114,17 +113,13 @@ lib/
**Attributes:** **Attributes:**
- `id` - UUID v7 primary key - `id` - UUID v7 primary key
- `name` - Unique group name (required, max 100 chars) - `name` - Unique group name (required, max 100 chars)
- `slug` - URL-friendly identifier (required, max 100 chars, auto-generated from name)
- `description` - Optional description (max 500 chars) - `description` - Optional description (max 500 chars)
- `inserted_at` / `updated_at` - Timestamps - `inserted_at` / `updated_at` - Timestamps
**Constraints:** **Constraints:**
- `name` must be unique (case-insensitive, using LOWER(name)) - `name` must be unique (case-insensitive, using LOWER(name))
- `slug` must be unique (case-sensitive, exact match)
- `name` cannot be null - `name` cannot be null
- `slug` cannot be null
- `name` max length: 100 characters - `name` max length: 100 characters
- `slug` max length: 100 characters
- `description` max length: 500 characters - `description` max length: 500 characters
#### `member_groups` Table (Join Table) #### `member_groups` Table (Join Table)
@ -156,19 +151,15 @@ lib/
- `member_count` - Integer calculation counting associated members - `member_count` - Integer calculation counting associated members
**Actions:** **Actions:**
- `create` - Create new group (auto-generates slug from name) - `create` - Create new group
- `read` - List/search groups (can query by slug via identity) - `read` - List/search groups
- `update` - Update group name/description (slug remains unchanged) - `update` - Update group name/description
- `destroy` - Delete group (with confirmation) - `destroy` - Delete group (with confirmation)
**Validations:** **Validations:**
- `name` required, unique (case-insensitive), max 100 chars - `name` required, unique (case-insensitive), max 100 chars
- `slug` required, unique (case-sensitive), max 100 chars, auto-generated, immutable
- `description` optional, max 500 chars - `description` optional, max 500 chars
**Identities:**
- `unique_slug` - Unique identity on `slug` for efficient lookups
#### `Mv.Membership.MemberGroup` #### `Mv.Membership.MemberGroup`
**Relationships:** **Relationships:**
@ -201,14 +192,12 @@ lib/
**Create Group:** **Create Group:**
- Validate name uniqueness - Validate name uniqueness
- Automatically generate slug from name (using `GenerateSlug` change, same pattern as CustomFields) - Generate slug (if needed for future URL-friendly identifiers)
- Validate slug uniqueness
- Return created group - Return created group
**Update Group:** **Update Group:**
- Validate name uniqueness (if name changed) - Validate name uniqueness (if name changed)
- Update description - Update description
- Slug remains unchanged (immutable after creation)
- Return updated group - Return updated group
**Delete Group:** **Delete Group:**
@ -306,7 +295,7 @@ lib/
### Group Detail View (`/groups/:id`) ### Group Detail View (`/groups/:id`)
**Route:** `/groups/:id` - Group detail page (uses UUID, slug can be used for future `/groups/:slug` routes) **Route:** `/groups/:id` - Group detail page
**Features:** **Features:**
- Display group name and description - Display group name and description
@ -315,8 +304,6 @@ lib/
- Edit group button - Edit group button
- Delete group button (with confirmation) - Delete group button (with confirmation)
**Note:** Currently uses UUID for routing. Slug is available for future URL-friendly routes (`/groups/:slug`).
### Accessibility (A11y) Considerations ### Accessibility (A11y) Considerations
**Requirements:** **Requirements:**
@ -444,8 +431,7 @@ lib/
### Database Indexes ### Database Indexes
**Critical Indexes:** **Critical Indexes:**
- `groups.name` - For uniqueness and search (case-insensitive via LOWER) - `groups.name` - For uniqueness and search
- `groups.slug` - For uniqueness and efficient lookups (unique index)
- `member_groups.member_id` - For member → groups queries - `member_groups.member_id` - For member → groups queries
- `member_groups.group_id` - For group → members queries - `member_groups.group_id` - For group → members queries
- Composite index on `(member_id, group_id)` - For uniqueness check - Composite index on `(member_id, group_id)` - For uniqueness check
@ -454,7 +440,7 @@ lib/
**Member Overview:** **Member Overview:**
- Load groups with members in single query using query preloading - Load groups with members in single query using query preloading
- Preload only necessary group attributes (id, name, slug) to minimize data transfer - Preload only necessary group attributes (id, name) to minimize data transfer
- Filter groups at database level when filtering by group - Filter groups at database level when filtering by group
**N+1 Query Prevention:** **N+1 Query Prevention:**
@ -545,7 +531,7 @@ The Groups feature is divided into **functionally complete, vertical units**. Ea
**Minimal Viable Product (MVP):** **Minimal Viable Product (MVP):**
The MVP includes the **core functionality** necessary to manage groups and assign them to members: The MVP includes the **core functionality** necessary to manage groups and assign them to members:
1. ✅ Create groups (Name + Description + Slug) 1. ✅ Create groups (Name + Description)
2. ✅ Edit groups 2. ✅ Edit groups
3. ✅ Delete groups (with confirmation) 3. ✅ Delete groups (with confirmation)
4. ✅ Assign members to groups 4. ✅ Assign members to groups
@ -568,10 +554,9 @@ The MVP includes the **core functionality** necessary to manage groups and assig
**Functional Scope:** Administrators can manage groups in the system **Functional Scope:** Administrators can manage groups in the system
**Scope:** **Scope:**
- Group resource (Name, Description, Slug) - Group resource (Name, Description)
- CRUD operations for groups - CRUD operations for groups
- Validations (unique name, unique slug, length limits) - Validations (unique name, length limits)
- Automatic slug generation from name
- Delete logic with cascade behavior - Delete logic with cascade behavior
**Deliverable:** Groups can be created, edited, and deleted via API **Deliverable:** Groups can be created, edited, and deleted via API
@ -746,13 +731,12 @@ Each functional unit can be implemented as a **separate issue**:
**Goal:** Basic group management and member assignment **Goal:** Basic group management and member assignment
**Tasks:** **Tasks:**
1. Create `Group` resource (name, description, slug) 1. Create `Group` resource (name, description)
2. Implement slug generation (reuse `GenerateSlug` change from CustomFields) 2. Create `MemberGroup` join table resource
3. Create `MemberGroup` join table resource 3. Extend `Member` with groups relationship
4. Extend `Member` with groups relationship 4. Database migrations
5. Database migrations (including slug column and unique index) 5. Basic CRUD actions for groups
6. Basic CRUD actions for groups 6. Add/remove members from groups (via group management)
7. Add/remove members from groups (via group management)
**Deliverables:** **Deliverables:**
- Groups can be created, edited, deleted - Groups can be created, edited, deleted
@ -857,18 +841,16 @@ Each functional unit can be implemented as a **separate issue**:
**Type:** Backend **Type:** Backend
**Estimation:** 4-5h **Estimation:** 4-5h
**Tasks:** **Tasks:**
- Create `Group` resource (with slug attribute and generation) - Create `Group` resource
- Create `MemberGroup` join table resource - Create `MemberGroup` join table resource
- Extend `Member` resource - Extend `Member` resource
- Database migrations (including slug column) - Database migrations
- Basic validations (name, slug, description) - Basic validations
**Acceptance Criteria:** **Acceptance Criteria:**
- Groups can be created via Ash API - Groups can be created via Ash API
- Slug is automatically generated from name
- Slug is unique and immutable
- Members can be associated with groups - Members can be associated with groups
- Database constraints enforced (unique name, unique slug, foreign keys) - Database constraints enforced
### Issue 2: Groups Management UI ### Issue 2: Groups Management UI
**Type:** Frontend **Type:** Frontend
@ -944,24 +926,6 @@ Each functional unit can be implemented as a **separate issue**:
## Testing Strategy ## Testing Strategy
### Testing Philosophy
**Focus on Business Logic, Not Framework Functionality**
We test our business logic and domain-specific behavior, not core framework features. Framework features (Ash validations, Ecto relationships, etc.) are already tested by their respective libraries.
**What We Test:**
- Business rules and validations specific to our domain
- Custom business logic (slug generation, calculations, etc.)
- Integration between our resources
- Database-level constraints (unique constraints, foreign keys, CASCADE)
- Query performance (N+1 prevention)
**What We Don't Test:**
- Framework core functionality (Ash validations work, Ecto relationships work, etc.)
- Standard CRUD operations without custom logic
- Framework-provided features that are already tested upstream
### Unit Tests ### Unit Tests
#### Group Resource Tests #### Group Resource Tests
@ -970,23 +934,20 @@ We test our business logic and domain-specific behavior, not core framework feat
**Test Cases:** **Test Cases:**
- Create group with valid attributes - Create group with valid attributes
- Return error when name is missing (required validation) - Return error when name is missing
- Return error when name exceeds 100 characters (length validation) - Return error when name exceeds 100 characters
- Return error when name is not unique (case-insensitive) - application level validation - Return error when name is not unique
- Allow description to be nil (optional field) - Name uniqueness is case-insensitive
- Description max length is 500 characters (length validation) - Allow description to be nil
- Slug is automatically generated from name on create (custom business logic) - Trim whitespace from name
- Slug is immutable (doesn't change when name is updated) - business rule - Description max length is 500 characters
- Slug is unique (prevents duplicate slugs from different names) - business rule
- Slug cannot be empty (rejects name with only special characters) - business rule
- Update group name and description - Update group name and description
- Prevent duplicate name on update (case-insensitive) - business rule - Prevent duplicate name on update
- Delete group and all member associations (cascade behavior) - Delete group and all member associations
- Do not delete members themselves (cascade boundary) - Do not delete members themselves
- Member count calculation returns 0 for empty group (custom calculation) - Member count calculation returns 0 for empty group
- Member count calculation returns correct count when members added/removed (custom calculation) - Member count calculation returns correct count when members added
- Member count updates correctly when members removed
**Note:** Detailed slug generation tests (Umlauts, truncation, etc.) are covered by the `GenerateSlug` change tests in `custom_field_slug_test.exs`, which is reused for groups. We don't duplicate these framework-level tests.
#### MemberGroup Resource Tests #### MemberGroup Resource Tests
@ -994,23 +955,9 @@ We test our business logic and domain-specific behavior, not core framework feat
**Test Cases:** **Test Cases:**
- Create association between member and group - Create association between member and group
- Prevent duplicate associations (unique constraint) - Prevent duplicate associations
- Cascade delete when member deleted (database constraint) - Cascade delete when member deleted
- Cascade delete when group deleted (database constraint) - Cascade delete when group deleted
#### Database Constraint Tests
**File:** `test/membership/group_database_constraints_test.exs`
**Test Cases:**
- Database enforces unique name constraint (case-insensitive via LOWER) - DB level
- Database enforces unique slug constraint (case-sensitive) - DB level
- Cannot create MemberGroup with non-existent member_id (foreign key constraint)
- Cannot create MemberGroup with non-existent group_id (foreign key constraint)
- Deleting member cascades to member_groups (verified at DB level)
- Deleting group cascades to member_groups (verified at DB level)
**Note:** These tests verify that constraints are enforced at the database level, not just application level.
### Integration Tests ### Integration Tests
@ -1019,20 +966,8 @@ We test our business logic and domain-specific behavior, not core framework feat
**File:** `test/membership/group_integration_test.exs` **File:** `test/membership/group_integration_test.exs`
**Test Cases:** **Test Cases:**
- Member can belong to multiple groups (many-to-many relationship) - Member can belong to multiple groups
- Group can contain multiple members (many-to-many relationship) - Group can contain multiple members
- Preloading groups with members avoids N+1 queries (performance test with query count verification)
**File:** `test/membership/member_groups_relationship_test.exs`
**Test Cases:**
- Member has many_to_many groups relationship (load with preloading)
- Load multiple members with groups preloaded (N+1 prevention)
- Add member to group via Ash API
- Remove member from group via Ash API
- Add member to multiple groups in single operation
- Adding member to same group twice fails (duplicate prevention)
- Removing member from group they're not in (idempotent, no error)
### UI Tests ### UI Tests
@ -1062,17 +997,11 @@ We test our business logic and domain-specific behavior, not core framework feat
**Migration 1: Create groups table** **Migration 1: Create groups table**
- Create table with UUID v7 primary key - Create table with UUID v7 primary key
- Add name field (required, unique, case-insensitive) - Add name field (required, unique, case-insensitive)
- Add slug field (required, unique, case-sensitive, auto-generated)
- Add description field (optional) - Add description field (optional)
- Add timestamps - Add timestamps
- Create unique index on lowercased name (for name uniqueness) - Create unique index on lowercased name
- Create unique index on slug (for slug uniqueness and lookups)
- Create index on lowercased name for search - Create index on lowercased name for search
**Note:** Slug generation uses the shared `Mv.Membership.Changes.GenerateSlug` change,
which is used by both CustomFields and Groups for consistent slug generation.
Slug is generated on create, immutable on update.
**Migration 2: Create member_groups join table** **Migration 2: Create member_groups join table**
- Create table with UUID v7 primary key - Create table with UUID v7 primary key
- Add member_id and group_id foreign keys - Add member_id and group_id foreign keys
@ -1101,13 +1030,4 @@ Slug is generated on create, immutable on update.
This architecture provides a solid foundation for the Groups feature while maintaining flexibility for future enhancements. The many-to-many relationship is implemented via a join table, following existing patterns in the codebase. The MVP focuses on core functionality (create, edit, delete groups, assign members) with clear extension points for hierarchical groups, roles, and advanced permissions. This architecture provides a solid foundation for the Groups feature while maintaining flexibility for future enhancements. The many-to-many relationship is implemented via a join table, following existing patterns in the codebase. The MVP focuses on core functionality (create, edit, delete groups, assign members) with clear extension points for hierarchical groups, roles, and advanced permissions.
**Slug Implementation:**
Groups include automatic slug generation, following the same pattern as CustomFields. Slugs are:
- Automatically generated from the `name` attribute on create
- Immutable after creation (don't change when name is updated)
- Unique and URL-friendly
- Available for future route enhancements (e.g., `/groups/:slug` instead of `/groups/:id`)
The implementation reuses the existing `GenerateSlug` change from CustomFields, ensuring consistency across the codebase.
The implementation is split into 6 manageable issues, totaling approximately 15 hours of work, aligning with the original estimation. Each phase builds on the previous one, allowing for incremental development and testing. The implementation is split into 6 manageable issues, totaling approximately 15 hours of work, aligning with the original estimation. Each phase builds on the previous one, allowing for incremental development and testing.

View file

@ -175,13 +175,6 @@ defmodule Mv.Accounts.User do
end end
end end
# Internal update used only by SystemActor/bootstrap and tests to assign role to system user.
# Not protected by system-user validation so bootstrap can run.
update :update_internal do
accept []
require_atomic? false
end
# Admin action for direct password changes in admin panel # Admin action for direct password changes in admin panel
# Uses the official Ash Authentication HashPasswordChange with correct context # Uses the official Ash Authentication HashPasswordChange with correct context
update :admin_set_password do update :admin_set_password do
@ -378,20 +371,18 @@ defmodule Mv.Accounts.User do
end end
end end
# Prevent modification of the system actor user (required for internal operations). # Prevent deletion of the system actor user (required for internal operations)
# Block update/destroy on UI-exposed actions only; :update_internal is used by bootstrap/tests.
validate fn changeset, _context -> validate fn changeset, _context ->
if Mv.Helpers.SystemActor.system_user?(changeset.data) do if to_string(changeset.data.email) == Mv.Helpers.SystemActor.system_user_email() do
{:error, {:error,
field: :email, field: :email,
message: message:
"Cannot modify system actor user. This user is required for internal operations."} "Cannot delete system actor user. This user is required for internal operations."}
else else
:ok :ok
end end
end, end,
on: [:update, :destroy], on: [:destroy]
where: [action_is([:update, :update_user, :admin_set_password, :destroy])]
end end
def validate_oidc_id_present(changeset, _context) do def validate_oidc_id_present(changeset, _context) do

View file

@ -63,7 +63,7 @@ defmodule Mv.Membership.CustomField do
create :create do create :create do
accept [:name, :value_type, :description, :required, :show_in_overview] accept [:name, :value_type, :description, :required, :show_in_overview]
change Mv.Membership.Changes.GenerateSlug change Mv.Membership.CustomField.Changes.GenerateSlug
validate string_length(:slug, min: 1) validate string_length(:slug, min: 1)
end end

View file

@ -1,4 +1,4 @@
defmodule Mv.Membership.Changes.GenerateSlug do defmodule Mv.Membership.CustomField.Changes.GenerateSlug do
@moduledoc """ @moduledoc """
Ash Change that automatically generates a URL-friendly slug from the `name` attribute. Ash Change that automatically generates a URL-friendly slug from the `name` attribute.
@ -14,26 +14,12 @@ defmodule Mv.Membership.Changes.GenerateSlug do
- Trims leading/trailing hyphens - Trims leading/trailing hyphens
- Truncates to max 100 characters - Truncates to max 100 characters
## Usage
Works for any resource with `name` and `slug` attributes.
Used by CustomField and Group resources.
create :create do
accept [:name, :description]
change Mv.Membership.Changes.GenerateSlug
validate string_length(:slug, min: 1)
end
## Examples ## Examples
# Create with automatic slug generation # Create with automatic slug generation
CustomField.create!(%{name: "Mobile Phone"}) CustomField.create!(%{name: "Mobile Phone"})
# => %CustomField{name: "Mobile Phone", slug: "mobile-phone"} # => %CustomField{name: "Mobile Phone", slug: "mobile-phone"}
Group.create!(%{name: "Test Group"})
# => %Group{name: "Test Group", slug: "test-group"}
# German umlauts are converted # German umlauts are converted
CustomField.create!(%{name: "Café Müller"}) CustomField.create!(%{name: "Café Müller"})
# => %CustomField{name: "Café Müller", slug: "cafe-muller"} # => %CustomField{name: "Café Müller", slug: "cafe-muller"}
@ -46,7 +32,7 @@ defmodule Mv.Membership.Changes.GenerateSlug do
## Implementation Note ## Implementation Note
This change only runs on `:create` actions. The slug is immutable by design, This change only runs on `:create` actions. The slug is immutable by design,
as changing slugs would break external references (e.g., CSV imports/exports, URL routes). as changing slugs would break external references (e.g., CSV imports/exports).
""" """
use Ash.Resource.Change use Ash.Resource.Change
@ -61,14 +47,11 @@ defmodule Mv.Membership.Changes.GenerateSlug do
## Parameters ## Parameters
- `changeset` - The Ash changeset - `changeset` - The Ash changeset
- `_opts` - Options passed to the change (unused)
- `_context` - Ash context map (unused)
## Returns ## Returns
The changeset with the `:slug` attribute set to the generated slug. The changeset with the `:slug` attribute set to the generated slug.
""" """
@impl true
def change(changeset, _opts, _context) do def change(changeset, _opts, _context) do
# Only generate slug on create, not on update (immutability) # Only generate slug on create, not on update (immutability)
if changeset.action_type == :create do if changeset.action_type == :create do
@ -79,9 +62,6 @@ defmodule Mv.Membership.Changes.GenerateSlug do
name when is_binary(name) -> name when is_binary(name) ->
slug = generate_slug(name) slug = generate_slug(name)
Ash.Changeset.force_change_attribute(changeset, :slug, slug) Ash.Changeset.force_change_attribute(changeset, :slug, slug)
_ ->
changeset
end end
else else
# On update, don't touch the slug (immutable) # On update, don't touch the slug (immutable)
@ -100,14 +80,6 @@ defmodule Mv.Membership.Changes.GenerateSlug do
- Leading/trailing hyphens removed - Leading/trailing hyphens removed
- Maximum length of 100 characters - Maximum length of 100 characters
## Parameters
- `name` - The string to convert to a slug
## Returns
A URL-friendly slug string, or empty string if input is invalid.
## Examples ## Examples
iex> generate_slug("Mobile Phone") iex> generate_slug("Mobile Phone")
@ -132,7 +104,6 @@ defmodule Mv.Membership.Changes.GenerateSlug do
"strasse" "strasse"
""" """
@spec generate_slug(String.t()) :: String.t()
def generate_slug(name) when is_binary(name) do def generate_slug(name) when is_binary(name) do
slug = Slug.slugify(name) slug = Slug.slugify(name)

View file

@ -1,161 +0,0 @@
defmodule Mv.Membership.Group do
@moduledoc """
Ash resource representing a group that members can belong to.
## Overview
Groups allow organizing members into categories (e.g., "Board Members", "Active Members").
Each member can belong to multiple groups, and each group can contain multiple members.
## Attributes
- `name` - Unique group name (required, max 100 chars, case-insensitive uniqueness)
- `slug` - URL-friendly identifier (required, max 100 chars, auto-generated from name, immutable)
- `description` - Optional description (max 500 chars)
## Relationships
- `has_many :member_groups` - Relationship to MemberGroup join table
- `many_to_many :members` - Relationship to Members through MemberGroup
## Constraints
- Name must be unique (case-insensitive, using LOWER(name) in database)
- Slug must be unique (case-sensitive, exact match)
- Name cannot be null
- Slug cannot be null
## Calculations
- `member_count` - Returns the number of members in this group
## Examples
# Create a new group
Group.create!(%{name: "Board Members", description: "Members of the board"})
# => %Group{name: "Board Members", slug: "board-members", ...}
# Update group (slug remains unchanged)
group = Group.get_by_slug!("board-members")
Group.update!(group, %{description: "Updated description"})
# => %Group{slug: "board-members", ...} # slug unchanged!
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer
require Ash.Query
alias Mv.Helpers
alias Mv.Helpers.SystemActor
require Logger
postgres do
table "groups"
repo Mv.Repo
end
actions do
defaults [:read, :destroy]
create :create do
accept [:name, :description]
change Mv.Membership.Changes.GenerateSlug
validate string_length(:slug, min: 1)
end
update :update do
accept [:name, :description]
require_atomic? false
end
end
validations do
validate present(:name)
# Case-insensitive name uniqueness validation
validate fn changeset, context ->
name = Ash.Changeset.get_attribute(changeset, :name)
current_id = Ash.Changeset.get_attribute(changeset, :id)
if name do
check_name_uniqueness(name, current_id, context)
else
:ok
end
end
end
attributes do
uuid_v7_primary_key :id
attribute :name, :string do
allow_nil? false
public? true
constraints max_length: 100,
trim?: true
end
attribute :slug, :string do
allow_nil? false
public? true
writable? false
constraints max_length: 100,
trim?: true
end
attribute :description, :string do
allow_nil? true
public? true
constraints max_length: 500,
trim?: true
end
timestamps()
end
relationships do
has_many :member_groups, Mv.Membership.MemberGroup
many_to_many :members, Mv.Membership.Member, through: Mv.Membership.MemberGroup
end
aggregates do
count :member_count, :member_groups
end
identities do
identity :unique_slug, [:slug]
end
# Private helper function for case-insensitive name uniqueness check
# Uses context actor if available (respects policies), falls back to system actor
defp check_name_uniqueness(name, exclude_id, context) do
# Use context actor if available (respects user permissions), otherwise fall back to system actor
actor =
case context do
%{actor: actor} when not is_nil(actor) -> actor
_ -> SystemActor.get_system_actor()
end
query =
Mv.Membership.Group
|> Ash.Query.filter(fragment("LOWER(?) = LOWER(?)", name, ^name))
|> maybe_exclude_id(exclude_id)
opts = Helpers.ash_actor_opts(actor)
case Ash.read(query, opts) do
{:ok, []} ->
:ok
{:ok, _} ->
{:error, field: :name, message: "has already been taken", value: name}
{:error, reason} ->
Logger.warning(
"Name uniqueness validation query failed for group name '#{name}': #{inspect(reason)}. Allowing operation to proceed (fail-open)."
)
:ok
end
end
defp maybe_exclude_id(query, nil), do: query
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
end

View file

@ -582,12 +582,6 @@ defmodule Mv.Membership.Member do
# has_many: All fee cycles for this member # has_many: All fee cycles for this member
has_many :membership_fee_cycles, Mv.MembershipFees.MembershipFeeCycle has_many :membership_fee_cycles, Mv.MembershipFees.MembershipFeeCycle
# Groups relationships
# has_many: All member-group associations for this member
has_many :member_groups, Mv.Membership.MemberGroup
# many_to_many: All groups this member belongs to (through MemberGroup)
many_to_many :groups, Mv.Membership.Group, through: Mv.Membership.MemberGroup
end end
calculations do calculations do

View file

@ -1,141 +0,0 @@
defmodule Mv.Membership.MemberGroup do
@moduledoc """
Ash resource representing the join table for the many-to-many relationship
between Members and Groups.
## Overview
MemberGroup is a join table that links members to groups. It enables the
many-to-many relationship where:
- A member can belong to multiple groups
- A group can contain multiple members
## Attributes
- `member_id` - Foreign key to Member (required)
- `group_id` - Foreign key to Group (required)
## Relationships
- `belongs_to :member` - Relationship to Member
- `belongs_to :group` - Relationship to Group
## Constraints
- Unique constraint on `(member_id, group_id)` - prevents duplicate memberships
- CASCADE delete: Removing member removes all group associations
- CASCADE delete: Removing group removes all member associations
## Examples
# Add member to group
{:ok, member_group} =
Membership.create_member_group(%{member_id: member.id, group_id: group.id})
# Remove member from group
{:ok, [member_group]} =
Ash.read(
Mv.Membership.MemberGroup
|> Ash.Query.filter(member_id == ^member.id and group_id == ^group.id),
domain: Mv.Membership
)
:ok = Membership.destroy_member_group(member_group)
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer
require Ash.Query
postgres do
table "member_groups"
repo Mv.Repo
end
actions do
defaults [:read, :destroy]
create :create do
accept [:member_id, :group_id]
end
end
validations do
validate present(:member_id)
validate present(:group_id)
# Prevent duplicate associations
validate fn changeset, context ->
member_id = Ash.Changeset.get_attribute(changeset, :member_id)
group_id = Ash.Changeset.get_attribute(changeset, :group_id)
current_id = Ash.Changeset.get_attribute(changeset, :id)
if member_id && group_id do
check_duplicate_association(member_id, group_id, current_id, context)
else
:ok
end
end
end
attributes do
uuid_v7_primary_key :id
attribute :member_id, :uuid do
allow_nil? false
end
attribute :group_id, :uuid do
allow_nil? false
end
timestamps()
end
relationships do
belongs_to :member, Mv.Membership.Member do
allow_nil? false
end
belongs_to :group, Mv.Membership.Group do
allow_nil? false
end
end
identities do
identity :unique_member_group, [:member_id, :group_id]
end
# Private helper function to check for duplicate associations
# Uses context actor if available (respects policies), falls back to system actor
defp check_duplicate_association(member_id, group_id, exclude_id, context) do
alias Mv.Helpers
alias Mv.Helpers.SystemActor
# Use context actor if available (respects user permissions), otherwise fall back to system actor
actor =
case context do
%{actor: actor} when not is_nil(actor) -> actor
_ -> SystemActor.get_system_actor()
end
query =
Mv.Membership.MemberGroup
|> Ash.Query.filter(member_id == ^member_id and group_id == ^group_id)
|> maybe_exclude_id(exclude_id)
opts = Helpers.ash_actor_opts(actor)
case Ash.read(query, opts) do
{:ok, []} ->
:ok
{:ok, _} ->
{:error, field: :member_id, message: "Member is already in this group", value: member_id}
{:error, _reason} ->
# Fail-open: if query fails, allow operation to proceed
# Database constraint will catch duplicates anyway
:ok
end
end
defp maybe_exclude_id(query, nil), do: query
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
end

View file

@ -7,8 +7,6 @@ defmodule Mv.Membership do
- `CustomFieldValue` - Dynamic custom field values attached to members - `CustomFieldValue` - Dynamic custom field values attached to members
- `CustomField` - Schema definitions for custom fields - `CustomField` - Schema definitions for custom fields
- `Setting` - Global application settings (singleton) - `Setting` - Global application settings (singleton)
- `Group` - Groups that members can belong to
- `MemberGroup` - Join table for many-to-many relationship between Members and Groups
## Public API ## Public API
The domain exposes these main actions: The domain exposes these main actions:
@ -16,8 +14,6 @@ defmodule Mv.Membership do
- Custom field value management: `create_custom_field_value/1`, `list_custom_field_values/0`, etc. - Custom field value management: `create_custom_field_value/1`, `list_custom_field_values/0`, etc.
- Custom field management: `create_custom_field/1`, `list_custom_fields/0`, `list_required_custom_fields/0`, etc. - Custom field management: `create_custom_field/1`, `list_custom_fields/0`, `list_required_custom_fields/0`, etc.
- Settings management: `get_settings/0`, `update_settings/2`, `update_member_field_visibility/2`, `update_single_member_field_visibility/3` - Settings management: `get_settings/0`, `update_settings/2`, `update_member_field_visibility/2`, `update_single_member_field_visibility/3`
- Group management: `create_group/1`, `list_groups/0`, `update_group/2`, `destroy_group/1`
- Member-group associations: `create_member_group/1`, `list_member_groups/0`, `destroy_member_group/1`
## Admin Interface ## Admin Interface
The domain is configured with AshAdmin for management UI. The domain is configured with AshAdmin for management UI.
@ -65,19 +61,6 @@ defmodule Mv.Membership do
define :update_single_member_field_visibility, define :update_single_member_field_visibility,
action: :update_single_member_field_visibility action: :update_single_member_field_visibility
end end
resource Mv.Membership.Group do
define :create_group, action: :create
define :list_groups, action: :read
define :update_group, action: :update
define :destroy_group, action: :destroy
end
resource Mv.Membership.MemberGroup do
define :create_member_group, action: :create
define :list_member_groups, action: :read
define :destroy_member_group, action: :destroy
end
end end
# Singleton pattern: Get the single settings record # Singleton pattern: Get the single settings record

View file

@ -172,31 +172,6 @@ defmodule Mv.Helpers.SystemActor do
end end
end end
@doc """
Returns whether the given user is the system actor user (case-insensitive email match).
Use this instead of ad-hoc `to_string(user.email) == system_user_email()` so
comparisons are consistent and case-insensitive everywhere.
## Returns
- `boolean()` - true if user's email matches system user email (case-insensitive)
## Examples
iex> Mv.Helpers.SystemActor.system_user?(user_with_system_email)
true
iex> Mv.Helpers.SystemActor.system_user?(other_user)
false
"""
@spec system_user?(Mv.Accounts.User.t() | map() | nil) :: boolean()
def system_user?(%{email: email}) when not is_nil(email) do
normalized_email(to_string(email)) == normalized_system_user_email()
end
def system_user?(_), do: false
@doc """ @doc """
Returns the email address of the system user. Returns the email address of the system user.
@ -216,11 +191,6 @@ defmodule Mv.Helpers.SystemActor do
@spec system_user_email() :: String.t() @spec system_user_email() :: String.t()
def system_user_email, do: system_user_email_config() def system_user_email, do: system_user_email_config()
# Case-insensitive normalized form for comparisons
defp normalized_system_user_email, do: normalized_email(system_user_email_config())
defp normalized_email(email) when is_binary(email), do: String.downcase(email)
# Returns the system user email from environment variable or default # Returns the system user email from environment variable or default
# This allows configuration via SYSTEM_ACTOR_EMAIL env var # This allows configuration via SYSTEM_ACTOR_EMAIL env var
@spec system_user_email_config() :: String.t() @spec system_user_email_config() :: String.t()
@ -398,7 +368,7 @@ defmodule Mv.Helpers.SystemActor do
upsert_identity: :unique_email, upsert_identity: :unique_email,
authorize?: false authorize?: false
) )
|> Ash.Changeset.for_update(:update_internal, %{}) |> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!(authorize?: false) |> Ash.update!(authorize?: false)
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false) |> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)

View file

@ -1,25 +0,0 @@
defmodule MvWeb.ErrorHelpers do
@moduledoc """
Shared helpers for formatting errors in the web layer.
Use `format_ash_error/1` for Ash errors so behaviour stays consistent
(e.g. handling Invalid errors whose entries may lack a `:message` field).
"""
@doc """
Formats an Ash error for display to the user.
Handles `Ash.Error.Invalid` by joining error messages; entries without
a `:message` field are inspected to avoid FunctionClauseError.
Other errors are inspected.
"""
@spec format_ash_error(Ash.Error.t() | term()) :: String.t()
def format_ash_error(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do
Enum.map_join(errors, ", ", fn
%{message: message} when is_binary(message) -> message
other -> inspect(other)
end)
end
def format_ash_error(error), do: inspect(error)
end

View file

@ -264,27 +264,29 @@ defmodule MvWeb.UserLive.Form do
def mount(params, _session, socket) do def mount(params, _session, socket) do
actor = current_actor(socket) actor = current_actor(socket)
case load_user_or_redirect(params["id"], actor, socket) do user =
{:redirect, socket} -> case params["id"] do
{:ok, socket} nil ->
nil
{:ok, user} -> id ->
mount_continue(user, params, socket) loaded =
end Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member], actor: actor)
end
defp load_user_or_redirect(nil, _actor, _socket), do: {:ok, nil} if to_string(loaded.email) == Mv.Helpers.SystemActor.system_user_email() do
{:redirect, loaded}
else
loaded
end
end
defp load_user_or_redirect(id, actor, socket) do if match?({:redirect, _}, user) do
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member], actor: actor) {:ok,
if Mv.Helpers.SystemActor.system_user?(user) do
{:redirect,
socket socket
|> put_flash(:error, gettext("This user cannot be edited.")) |> put_flash(:error, gettext("This user cannot be edited."))
|> push_navigate(to: ~p"/users")} |> push_navigate(to: ~p"/users")}
else else
{:ok, user} mount_continue(user, params, socket)
end end
end end

View file

@ -26,7 +26,6 @@ defmodule MvWeb.UserLive.Index do
import MvWeb.LiveHelpers, only: [current_actor: 1] import MvWeb.LiveHelpers, only: [current_actor: 1]
require Ash.Query require Ash.Query
import MvWeb.ErrorHelpers, only: [format_ash_error: 1]
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
@ -72,7 +71,7 @@ defmodule MvWeb.UserLive.Index do
)} )}
{:error, error} -> {:error, error} ->
{:noreply, put_flash(socket, :error, format_ash_error(error))} {:noreply, put_flash(socket, :error, format_error(error))}
end end
{:error, %Ash.Error.Query.NotFound{}} -> {:error, %Ash.Error.Query.NotFound{}} ->
@ -83,7 +82,7 @@ defmodule MvWeb.UserLive.Index do
put_flash(socket, :error, gettext("You do not have permission to access this user"))} put_flash(socket, :error, gettext("You do not have permission to access this user"))}
{:error, error} -> {:error, error} ->
{:noreply, put_flash(socket, :error, format_ash_error(error))} {:noreply, put_flash(socket, :error, format_error(error))}
end end
end end
@ -145,4 +144,15 @@ defmodule MvWeb.UserLive.Index do
defp toggle_order(:desc), do: :asc defp toggle_order(:desc), do: :asc
defp sort_fun(:asc), do: &<=/2 defp sort_fun(:asc), do: &<=/2
defp sort_fun(:desc), do: &>=/2 defp sort_fun(:desc), do: &>=/2
defp format_error(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do
Enum.map_join(errors, ", ", fn
%{message: message} when is_binary(message) -> message
other -> inspect(other)
end)
end
defp format_error(error) do
inspect(error)
end
end end

View file

@ -75,7 +75,7 @@ defmodule MvWeb.UserLive.Show do
actor = current_actor(socket) actor = current_actor(socket)
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member], actor: actor) user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member], actor: actor)
if Mv.Helpers.SystemActor.system_user?(user) do if to_string(user.email) == Mv.Helpers.SystemActor.system_user_email() do
{:ok, {:ok,
socket socket
|> put_flash(:error, gettext("This user cannot be viewed.")) |> put_flash(:error, gettext("This user cannot be viewed."))

View file

@ -44,13 +44,10 @@ defmodule Mv.Repo.Migrations.EnsureSystemActorUserExists do
defp ensure_system_actor_user_exists(_admin_role_id) do defp ensure_system_actor_user_exists(_admin_role_id) do
case repo().one(from(u in "users", where: u.email == ^@system_user_email, select: u.id)) do case repo().one(from(u in "users", where: u.email == ^@system_user_email, select: u.id)) do
nil -> nil ->
# Use subquery for role_id to avoid nil/empty-string UUID (CI can lag after role insert)
execute(""" execute("""
INSERT INTO users (id, email, hashed_password, oidc_id, member_id, role_id) INSERT INTO users (id, email, hashed_password, oidc_id, member_id, role_id)
SELECT uuid_generate_v7(), '#{@system_user_email}', NULL, NULL, NULL, r.id SELECT gen_random_uuid(), '#{@system_user_email}', NULL, NULL, NULL, r.id
FROM roles r FROM roles r WHERE r.name = 'Admin' LIMIT 1
WHERE r.name = 'Admin'
LIMIT 1
""") """)
IO.puts("✅ Created system actor user (#{@system_user_email})") IO.puts("✅ Created system actor user (#{@system_user_email})")

View file

@ -1,116 +0,0 @@
defmodule Mv.Repo.Migrations.AddGroupsAndMemberGroups do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
create table(:member_groups, primary_key: false) do
add :id, :uuid, null: false, default: fragment("uuid_generate_v7()"), primary_key: true
add :member_id,
references(:members,
column: :id,
name: "member_groups_member_id_fkey",
type: :uuid,
prefix: "public",
on_delete: :delete_all
),
null: false
add :group_id, :uuid, null: false
add :inserted_at, :utc_datetime_usec,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
add :updated_at, :utc_datetime_usec,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
end
create table(:groups, primary_key: false) do
add :id, :uuid, null: false, default: fragment("uuid_generate_v7()"), primary_key: true
end
alter table(:member_groups) do
modify :group_id,
references(:groups,
column: :id,
name: "member_groups_group_id_fkey",
type: :uuid,
prefix: "public",
on_delete: :delete_all
)
end
# Unique constraint on (member_id, group_id) to prevent duplicate associations
create unique_index(:member_groups, [:member_id, :group_id],
name: "member_groups_unique_member_group_index"
)
# Indexes for efficient queries
create index(:member_groups, [:member_id], name: "member_groups_member_id_index")
create index(:member_groups, [:group_id], name: "member_groups_group_id_index")
alter table(:groups) do
add :name, :text, null: false
add :slug, :text, null: false
add :description, :text
add :inserted_at, :utc_datetime_usec,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
add :updated_at, :utc_datetime_usec,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
end
# Unique index on slug (case-sensitive)
create unique_index(:groups, [:slug], name: "groups_unique_slug_index")
# Unique index on LOWER(name) for case-insensitive uniqueness
# Using execute because Ecto doesn't support fragment in index column list
execute(
"CREATE UNIQUE INDEX groups_unique_name_lower_index ON groups (LOWER(name))",
"DROP INDEX IF EXISTS groups_unique_name_lower_index"
)
end
def down do
execute("DROP INDEX IF EXISTS groups_unique_name_lower_index")
drop_if_exists unique_index(:groups, [:slug], name: "groups_unique_slug_index")
alter table(:groups) do
remove :updated_at
remove :inserted_at
remove :description
remove :slug
remove :name
end
drop_if_exists index(:member_groups, [:group_id], name: "member_groups_group_id_index")
drop_if_exists index(:member_groups, [:member_id], name: "member_groups_member_id_index")
drop_if_exists unique_index(:member_groups, [:member_id, :group_id],
name: "member_groups_unique_member_group_index"
)
drop constraint(:member_groups, "member_groups_group_id_fkey")
alter table(:member_groups) do
modify :group_id, :uuid
end
drop table(:groups)
drop constraint(:member_groups, "member_groups_member_id_fkey")
drop table(:member_groups)
end
end

View file

@ -268,9 +268,9 @@ case Accounts.User
|> Ash.read_one(domain: Mv.Accounts, authorize?: false) do |> Ash.read_one(domain: Mv.Accounts, authorize?: false) do
{:ok, existing_system_user} when not is_nil(existing_system_user) -> {:ok, existing_system_user} when not is_nil(existing_system_user) ->
# System user already exists - ensure it has admin role # System user already exists - ensure it has admin role
# Use authorize?: false for bootstrap; :update_internal bypasses system-user modification block # Use authorize?: false for bootstrap
existing_system_user existing_system_user
|> Ash.Changeset.for_update(:update_internal, %{}) |> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!(authorize?: false) |> Ash.update!(authorize?: false)
@ -287,7 +287,7 @@ case Accounts.User
upsert_identity: :unique_email, upsert_identity: :unique_email,
authorize?: false authorize?: false
) )
|> Ash.Changeset.for_update(:update_internal, %{}) |> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!(authorize?: false) |> Ash.update!(authorize?: false)

View file

@ -1,106 +0,0 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"uuid_generate_v7()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "slug",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "description",
"type": "text"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "inserted_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "EB2489A9C4F649CBBDBD5E0685F703F10AF04448FB01A424801EEE36BAFF1A4A",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "groups_unique_slug_index",
"keys": [
{
"type": "atom",
"value": "slug"
}
],
"name": "unique_slug",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "groups"
}

View file

@ -1,136 +0,0 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"uuid_generate_v7()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "member_groups_member_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "members"
},
"scale": null,
"size": null,
"source": "member_id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "member_groups_group_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "groups"
},
"scale": null,
"size": null,
"source": "group_id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "inserted_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "6A81B894ADE7993917E2F97AB0C7233894AA7E59126DF2C17A7F04AEBDA6C159",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "member_groups_unique_member_group_index",
"keys": [
{
"type": "atom",
"value": "member_id"
},
{
"type": "atom",
"value": "group_id"
}
],
"name": "unique_member_group",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "member_groups"
}

View file

@ -1,141 +0,0 @@
defmodule Mv.Membership.GroupDatabaseConstraintsTest do
@moduledoc """
Tests for database-level constraints (unique, foreign keys, CASCADE).
These tests verify that constraints are enforced at the database level, not just application level.
"""
use Mv.DataCase, async: false
alias Mv.Membership
require Ash.Query
import Ash.Expr
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "Unique Constraints" do
test "database enforces unique name constraint (case-insensitive via LOWER)", %{actor: actor} do
{:ok, _group1} = Membership.create_group(%{name: "Test Group"}, actor: actor)
# Try to create with same name, different case - should fail at DB level
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_group(%{name: "TEST GROUP"}, actor: actor)
assert Enum.any?(errors, fn err ->
err.field == :name and
(String.contains?(err.message, "already been taken") or
String.contains?(err.message, "already exists") or
String.contains?(err.message, "duplicate"))
end)
end
test "database enforces unique slug constraint (case-sensitive)", %{actor: actor} do
{:ok, _group1} = Membership.create_group(%{name: "Test Group"}, actor: actor)
# Try to create with name that generates same slug - should fail at DB level
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_group(%{name: "test-group"}, actor: actor)
assert Enum.any?(errors, fn err ->
(err.field == :slug or err.field == :name) and
(String.contains?(err.message, "already been taken") or
String.contains?(err.message, "already exists") or
String.contains?(err.message, "duplicate"))
end)
end
end
describe "Foreign Key Constraints" do
test "cannot create MemberGroup with non-existent member_id", %{actor: actor} do
{:ok, group} = Membership.create_group(%{name: "Test Group"}, actor: actor)
fake_member_id = Ash.UUID.generate()
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_member_group(%{member_id: fake_member_id, group_id: group.id},
actor: actor
)
assert Enum.any?(errors, fn err ->
(err.field == :member_id or err.field == :member) and
(String.contains?(err.message, "does not exist") or
String.contains?(err.message, "not found") or
String.contains?(err.message, "foreign key"))
end)
end
test "cannot create MemberGroup with non-existent group_id", %{actor: actor} do
{:ok, member} = Membership.create_member(%{email: "test@test.com"}, actor: actor)
fake_group_id = Ash.UUID.generate()
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_member_group(%{member_id: member.id, group_id: fake_group_id},
actor: actor
)
assert Enum.any?(errors, fn err ->
(err.field == :group_id or err.field == :group) and
(String.contains?(err.message, "does not exist") or
String.contains?(err.message, "not found") or
String.contains?(err.message, "foreign key"))
end)
end
end
describe "CASCADE Delete Constraints" do
test "deleting member cascades to member_groups (verified at DB level)", %{actor: actor} do
{:ok, member} = Membership.create_member(%{email: "test@test.com"}, actor: actor)
{:ok, group} = Membership.create_group(%{name: "Test Group"}, actor: actor)
{:ok, member_group} =
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: actor
)
# Verify association exists
assert member_group.member_id == member.id
# Delete member
:ok = Membership.destroy_member(member, actor: actor)
# Verify MemberGroup is deleted at DB level (CASCADE)
{:ok, mgs} =
Ash.read(
Mv.Membership.MemberGroup
|> Ash.Query.filter(expr(id == ^member_group.id)),
actor: actor,
domain: Mv.Membership
)
assert mgs == []
end
test "deleting group cascades to member_groups (verified at DB level)", %{actor: actor} do
{:ok, member} = Membership.create_member(%{email: "test@test.com"}, actor: actor)
{:ok, group} = Membership.create_group(%{name: "Test Group"}, actor: actor)
{:ok, member_group} =
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: actor
)
# Verify association exists
assert member_group.group_id == group.id
# Delete group
:ok = Membership.destroy_group(group, actor: actor)
# Verify MemberGroup is deleted at DB level (CASCADE)
{:ok, mgs} =
Ash.read(
Mv.Membership.MemberGroup
|> Ash.Query.filter(expr(id == ^member_group.id)),
actor: actor,
domain: Mv.Membership
)
assert mgs == []
end
end
end

View file

@ -1,140 +0,0 @@
defmodule Mv.Membership.GroupIntegrationTest do
@moduledoc """
Integration tests for many-to-many relationships and query performance.
"""
use Mv.DataCase, async: false
alias Mv.Membership
require Ash.Query
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "Many-to-Many Relationship" do
test "member can belong to multiple groups", %{actor: actor} do
{:ok, member} = Membership.create_member(%{email: "test@test.com"}, actor: actor)
{:ok, group1} = Membership.create_group(%{name: "Group One"}, actor: actor)
{:ok, group2} = Membership.create_group(%{name: "Group Two"}, actor: actor)
{:ok, group3} = Membership.create_group(%{name: "Group Three"}, actor: actor)
# Add member to all groups
{:ok, _mg1} =
Membership.create_member_group(%{member_id: member.id, group_id: group1.id},
actor: actor
)
{:ok, _mg2} =
Membership.create_member_group(%{member_id: member.id, group_id: group2.id},
actor: actor
)
{:ok, _mg3} =
Membership.create_member_group(%{member_id: member.id, group_id: group3.id},
actor: actor
)
# Load member with groups
{:ok, member_with_groups} =
Ash.load(member, :groups, actor: actor, domain: Mv.Membership)
assert length(member_with_groups.groups) == 3
assert Enum.any?(member_with_groups.groups, &(&1.id == group1.id))
assert Enum.any?(member_with_groups.groups, &(&1.id == group2.id))
assert Enum.any?(member_with_groups.groups, &(&1.id == group3.id))
end
test "group can contain multiple members", %{actor: actor} do
{:ok, member1} = Membership.create_member(%{email: "member1@test.com"}, actor: actor)
{:ok, member2} = Membership.create_member(%{email: "member2@test.com"}, actor: actor)
{:ok, member3} = Membership.create_member(%{email: "member3@test.com"}, actor: actor)
{:ok, group} = Membership.create_group(%{name: "Test Group"}, actor: actor)
# Add all members to group
{:ok, _mg1} =
Membership.create_member_group(%{member_id: member1.id, group_id: group.id},
actor: actor
)
{:ok, _mg2} =
Membership.create_member_group(%{member_id: member2.id, group_id: group.id},
actor: actor
)
{:ok, _mg3} =
Membership.create_member_group(%{member_id: member3.id, group_id: group.id},
actor: actor
)
# Load group with members
{:ok, group_with_members} =
Ash.load(group, :members, actor: actor, domain: Mv.Membership)
assert length(group_with_members.members) == 3
assert Enum.any?(group_with_members.members, &(&1.id == member1.id))
assert Enum.any?(group_with_members.members, &(&1.id == member2.id))
assert Enum.any?(group_with_members.members, &(&1.id == member3.id))
end
end
describe "Query Performance" do
test "preloading groups with members avoids N+1 queries", %{actor: actor} do
# Create test data
{:ok, member1} = Membership.create_member(%{email: "member1@test.com"}, actor: actor)
{:ok, member2} = Membership.create_member(%{email: "member2@test.com"}, actor: actor)
{:ok, group1} = Membership.create_group(%{name: "Group One"}, actor: actor)
{:ok, group2} = Membership.create_group(%{name: "Group Two"}, actor: actor)
# Create associations
{:ok, _mg1} =
Membership.create_member_group(%{member_id: member1.id, group_id: group1.id},
actor: actor
)
{:ok, _mg2} =
Membership.create_member_group(%{member_id: member1.id, group_id: group2.id},
actor: actor
)
{:ok, _mg3} =
Membership.create_member_group(%{member_id: member2.id, group_id: group1.id},
actor: actor
)
# Count queries using Telemetry
query_count = Agent.start_link(fn -> 0 end) |> elem(1)
handler = fn _event, _measurements, _metadata, _config ->
Agent.update(query_count, &(&1 + 1))
end
:telemetry.attach("test-query-counter", [:ash, :query, :start], handler, nil)
# Load all members with groups preloaded (should be efficient with JOIN)
{:ok, members} =
Ash.read(Mv.Membership.Member, actor: actor, domain: Mv.Membership, load: [:groups])
final_count = Agent.get(query_count, & &1)
:telemetry.detach("test-query-counter")
member1_loaded = Enum.find(members, &(&1.id == member1.id))
member2_loaded = Enum.find(members, &(&1.id == member2.id))
# Verify preloading worked
assert length(member1_loaded.groups) == 2
assert length(member2_loaded.groups) == 1
# Verify groups are correctly associated
assert Enum.any?(member1_loaded.groups, &(&1.id == group1.id))
assert Enum.any?(member1_loaded.groups, &(&1.id == group2.id))
assert Enum.any?(member2_loaded.groups, &(&1.id == group1.id))
# Verify query count is reasonable (should be 2 queries: one for members, one for groups)
# Note: Exact count may vary based on Ash implementation, but should be much less than N+1
assert final_count <= 3,
"Expected max 3 queries (members + groups + possible count), got #{final_count}. This suggests N+1 query problem."
end
end
end

View file

@ -1,296 +0,0 @@
defmodule Mv.Membership.GroupTest do
@moduledoc """
Tests for Group resource validations, CRUD operations, and relationships.
"""
use Mv.DataCase, async: true
alias Mv.Membership
require Ash.Query
import Ash.Expr
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "Validations - Name & Description" do
@valid_attrs %{
name: "Test Group",
description: "Test description"
}
test "create group with valid attributes", %{actor: actor} do
assert {:ok, group} = Membership.create_group(@valid_attrs, actor: actor)
assert group.name == "Test Group"
assert group.description == "Test description"
assert group.slug != nil
end
test "create group with name only (description nil)", %{actor: actor} do
attrs = Map.delete(@valid_attrs, :description)
assert {:ok, group} = Membership.create_group(attrs, actor: actor)
assert group.name == "Test Group"
assert group.description == nil
end
test "return error when name is missing", %{actor: actor} do
attrs = Map.delete(@valid_attrs, :name)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_group(attrs, actor: actor)
assert error_message(errors, :name) =~ "must be present"
end
test "return error when name exceeds 100 characters", %{actor: actor} do
long_name = String.duplicate("a", 101)
attrs = Map.put(@valid_attrs, :name, long_name)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_group(attrs, actor: actor)
assert error_message(errors, :name) =~ "100" or error_message(errors, :name) =~ "length"
end
test "return error when name is not unique (case-insensitive) - application level validation",
%{
actor: actor
} do
{:ok, _group1} = Membership.create_group(@valid_attrs, actor: actor)
# Try to create with same name, different case
# This tests application-level validation (Ash validations)
attrs2 = Map.put(@valid_attrs, :name, "TEST GROUP")
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_group(attrs2, actor: actor)
error_msg = error_message(errors, :name)
assert error_msg =~ "already been taken" || error_msg =~ "already exists"
end
test "description max length is 500 characters", %{actor: actor} do
long_description = String.duplicate("a", 501)
attrs = Map.put(@valid_attrs, :description, long_description)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_group(attrs, actor: actor)
assert error_message(errors, :description) =~ "500" or
error_message(errors, :description) =~ "length"
end
end
describe "Slug Generation & Validation" do
test "slug is automatically generated from name on create", %{actor: actor} do
{:ok, group} =
Membership.create_group(%{name: "Test Group Name"}, actor: actor)
assert group.slug == "test-group-name"
end
test "slug is unique (prevents duplicate slugs from different names)", %{actor: actor} do
{:ok, _group1} =
Membership.create_group(%{name: "Test!!!"}, actor: actor)
# Second group with name that generates same slug should fail
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_group(%{name: "Test???"}, actor: actor)
assert Enum.any?(errors, fn err ->
(err.field == :slug or err.field == :name) and
(String.contains?(err.message, "already been taken") or
String.contains?(err.message, "already exists"))
end)
end
test "slug is immutable (doesn't change when name is updated)", %{actor: actor} do
{:ok, group} =
Membership.create_group(%{name: "Original Name"}, actor: actor)
original_slug = group.slug
assert original_slug == "original-name"
{:ok, updated_group} =
Membership.update_group(group, %{name: "New Different Name"}, actor: actor)
assert updated_group.slug == original_slug
assert updated_group.name == "New Different Name"
end
test "slug cannot be empty (rejects name with only special characters)", %{actor: actor} do
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_group(%{name: "!!!"}, actor: actor)
assert Enum.any?(errors, fn err ->
field = Map.get(err, :field)
message = Map.get(err, :message, Exception.message(err))
(field == :slug or field == :name) and
(String.contains?(message, "cannot be empty") or
String.contains?(message, "is required") or
String.contains?(message, "must be present"))
end)
end
end
describe "CRUD Operations" do
test "create group with name and description", %{actor: actor} do
attrs = %{name: "New Group", description: "Description"}
assert {:ok, group} = Membership.create_group(attrs, actor: actor)
assert group.name == "New Group"
assert group.description == "Description"
end
test "update group name (slug remains unchanged)", %{actor: actor} do
{:ok, group} = Membership.create_group(%{name: "Original"}, actor: actor)
original_slug = group.slug
{:ok, updated} = Membership.update_group(group, %{name: "Updated"}, actor: actor)
assert updated.name == "Updated"
assert updated.slug == original_slug
end
test "update group description", %{actor: actor} do
{:ok, group} =
Membership.create_group(%{name: "Test", description: "Old"}, actor: actor)
{:ok, updated} =
Membership.update_group(group, %{description: "New Description"}, actor: actor)
assert updated.description == "New Description"
end
test "prevent duplicate name on update (case-insensitive)", %{actor: actor} do
{:ok, _group1} = Membership.create_group(%{name: "Group One"}, actor: actor)
{:ok, group2} = Membership.create_group(%{name: "Group Two"}, actor: actor)
# Try to update group2 with group1's name (different case)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.update_group(group2, %{name: "GROUP ONE"}, actor: actor)
error_msg = error_message(errors, :name)
assert error_msg =~ "already been taken" || error_msg =~ "already exists"
end
end
describe "Calculations" do
test "member count calculation returns 0 for empty group", %{actor: actor} do
{:ok, group} = Membership.create_group(%{name: "Empty Group"}, actor: actor)
# Load with calculation
{:ok, group_with_count} =
Ash.load(group, :member_count, actor: actor, domain: Mv.Membership)
assert group_with_count.member_count == 0
end
test "member count calculation returns correct count when members added/removed", %{
actor: actor
} do
{:ok, group} = Membership.create_group(%{name: "Test Group"}, actor: actor)
{:ok, member1} = Membership.create_member(%{email: "member1@test.com"}, actor: actor)
{:ok, member2} = Membership.create_member(%{email: "member2@test.com"}, actor: actor)
# Add members to group
{:ok, _mg1} =
Membership.create_member_group(%{member_id: member1.id, group_id: group.id},
actor: actor
)
{:ok, _mg2} =
Membership.create_member_group(%{member_id: member2.id, group_id: group.id},
actor: actor
)
# Check count
{:ok, group_with_count} =
Ash.load(group, :member_count, actor: actor, domain: Mv.Membership)
assert group_with_count.member_count == 2
# Remove one member
{:ok, mg_to_delete} =
Ash.read_one(
Mv.Membership.MemberGroup
|> Ash.Query.filter(expr(member_id == ^member1.id and group_id == ^group.id)),
actor: actor,
domain: Mv.Membership
)
:ok = Membership.destroy_member_group(mg_to_delete, actor: actor)
# Check count again
{:ok, group_with_count_updated} =
Ash.load(group, :member_count, actor: actor, domain: Mv.Membership)
assert group_with_count_updated.member_count == 1
end
end
describe "Relationships & Deletion" do
test "group has many_to_many members relationship (load with preloading)", %{actor: actor} do
{:ok, group} = Membership.create_group(%{name: "Test Group"}, actor: actor)
{:ok, member} = Membership.create_member(%{email: "test@test.com"}, actor: actor)
{:ok, _mg} =
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: actor
)
# Load group with members
{:ok, group_with_members} =
Ash.load(group, :members, actor: actor, domain: Mv.Membership)
assert length(group_with_members.members) == 1
assert hd(group_with_members.members).id == member.id
end
test "delete group cascades to member_groups (members remain intact)", %{actor: actor} do
{:ok, group} = Membership.create_group(%{name: "Test Group"}, actor: actor)
{:ok, member} = Membership.create_member(%{email: "test@test.com"}, actor: actor)
{:ok, _mg} =
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: actor
)
# Delete group
:ok = Membership.destroy_group(group, actor: actor)
# Member should still exist
{:ok, member_reloaded} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
assert member_reloaded != nil
# MemberGroup should be deleted
{:ok, mgs} =
Ash.read(
Mv.Membership.MemberGroup
|> Ash.Query.filter(expr(group_id == ^group.id)),
actor: actor,
domain: Mv.Membership
)
assert mgs == []
end
end
# Helper function for error evaluation
# Returns the error message for a given field, or empty string if not found
defp error_message(errors, field) do
case Enum.find(errors, fn err -> Map.get(err, :field) == field end) do
nil ->
""
err ->
# Handle different error types (Ash.Error.Changes.Required doesn't have :message)
case Map.get(err, :message) do
nil -> Exception.message(err)
message -> message
end
end
end
end

View file

@ -1,120 +0,0 @@
defmodule Mv.Membership.MemberGroupTest do
@moduledoc """
Tests for MemberGroup join table resource - validations and cascade delete behavior.
"""
use Mv.DataCase, async: true
alias Mv.Membership
require Ash.Query
import Ash.Expr
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "Validations & Associations" do
test "create association between member and group", %{actor: actor} do
{:ok, member} = Membership.create_member(%{email: "test@test.com"}, actor: actor)
{:ok, group} = Membership.create_group(%{name: "Test Group"}, actor: actor)
assert {:ok, member_group} =
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: actor
)
assert member_group.member_id == member.id
assert member_group.group_id == group.id
end
test "prevent duplicate associations (same member + same group)", %{actor: actor} do
{:ok, member} = Membership.create_member(%{email: "test@test.com"}, actor: actor)
{:ok, group} = Membership.create_group(%{name: "Test Group"}, actor: actor)
{:ok, _mg1} =
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: actor
)
# Try to create duplicate
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: actor
)
assert Enum.any?(errors, fn err ->
field = Map.get(err, :field)
message = Map.get(err, :message, "")
(field == :member_id or field == :group_id) and
(String.contains?(message, "already been taken") or
String.contains?(message, "already exists") or
String.contains?(message, "duplicate") or
String.contains?(message, "already in this group"))
end)
end
end
describe "Cascade Delete Behavior" do
test "cascade delete when member deleted (MemberGroup deleted, Group remains)", %{
actor: actor
} do
{:ok, member} = Membership.create_member(%{email: "test@test.com"}, actor: actor)
{:ok, group} = Membership.create_group(%{name: "Test Group"}, actor: actor)
{:ok, _mg} =
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: actor
)
# Delete member
:ok = Membership.destroy_member(member, actor: actor)
# Group should still exist
{:ok, group_reloaded} = Ash.get(Mv.Membership.Group, group.id, actor: actor)
assert group_reloaded != nil
# MemberGroup should be deleted
{:ok, mgs} =
Ash.read(
Mv.Membership.MemberGroup
|> Ash.Query.filter(expr(member_id == ^member.id)),
actor: actor,
domain: Mv.Membership
)
assert mgs == []
end
test "cascade delete when group deleted (MemberGroup deleted, Member remains)", %{
actor: actor
} do
{:ok, member} = Membership.create_member(%{email: "test@test.com"}, actor: actor)
{:ok, group} = Membership.create_group(%{name: "Test Group"}, actor: actor)
{:ok, _mg} =
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: actor
)
# Delete group
:ok = Membership.destroy_group(group, actor: actor)
# Member should still exist
{:ok, member_reloaded} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
assert member_reloaded != nil
# MemberGroup should be deleted
{:ok, mgs} =
Ash.read(
Mv.Membership.MemberGroup
|> Ash.Query.filter(expr(group_id == ^group.id)),
actor: actor,
domain: Mv.Membership
)
assert mgs == []
end
end
end

View file

@ -1,197 +0,0 @@
defmodule Mv.Membership.MemberGroupsRelationshipTest do
@moduledoc """
Tests for Member resource extension with groups relationship.
"""
use Mv.DataCase, async: true
alias Mv.Membership
require Ash.Query
import Ash.Expr
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "Relationships" do
test "member has many_to_many groups relationship (load with preloading)", %{actor: actor} do
{:ok, member} = Membership.create_member(%{email: "test@test.com"}, actor: actor)
{:ok, group1} = Membership.create_group(%{name: "Group One"}, actor: actor)
{:ok, group2} = Membership.create_group(%{name: "Group Two"}, actor: actor)
{:ok, _mg1} =
Membership.create_member_group(%{member_id: member.id, group_id: group1.id},
actor: actor
)
{:ok, _mg2} =
Membership.create_member_group(%{member_id: member.id, group_id: group2.id},
actor: actor
)
# Load member with groups
{:ok, member_with_groups} =
Ash.load(member, :groups, actor: actor, domain: Mv.Membership)
assert length(member_with_groups.groups) == 2
assert Enum.any?(member_with_groups.groups, &(&1.id == group1.id))
assert Enum.any?(member_with_groups.groups, &(&1.id == group2.id))
end
test "load multiple members with groups preloaded (N+1 prevention)", %{actor: actor} do
{:ok, member1} = Membership.create_member(%{email: "member1@test.com"}, actor: actor)
{:ok, member2} = Membership.create_member(%{email: "member2@test.com"}, actor: actor)
{:ok, group} = Membership.create_group(%{name: "Test Group"}, actor: actor)
{:ok, _mg1} =
Membership.create_member_group(%{member_id: member1.id, group_id: group.id},
actor: actor
)
{:ok, _mg2} =
Membership.create_member_group(%{member_id: member2.id, group_id: group.id},
actor: actor
)
# Load all members with groups in single query
{:ok, members} =
Ash.read(Mv.Membership.Member, actor: actor, domain: Mv.Membership, load: [:groups])
member1_loaded = Enum.find(members, &(&1.id == member1.id))
member2_loaded = Enum.find(members, &(&1.id == member2.id))
assert length(member1_loaded.groups) == 1
assert length(member2_loaded.groups) == 1
assert hd(member1_loaded.groups).id == group.id
assert hd(member2_loaded.groups).id == group.id
end
end
describe "Member-Group Association Operations" do
test "add member to group via Ash API", %{actor: actor} do
{:ok, member} = Membership.create_member(%{email: "test@test.com"}, actor: actor)
{:ok, group} = Membership.create_group(%{name: "Test Group"}, actor: actor)
assert {:ok, member_group} =
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: actor
)
assert member_group.member_id == member.id
assert member_group.group_id == group.id
end
test "remove member from group via Ash API", %{actor: actor} do
{:ok, member} = Membership.create_member(%{email: "test@test.com"}, actor: actor)
{:ok, group} = Membership.create_group(%{name: "Test Group"}, actor: actor)
{:ok, member_group} =
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: actor
)
# Remove association
:ok = Membership.destroy_member_group(member_group, actor: actor)
# Verify association is removed
{:ok, mgs} =
Ash.read(
Mv.Membership.MemberGroup
|> Ash.Query.filter(expr(member_id == ^member.id and group_id == ^group.id)),
actor: actor,
domain: Mv.Membership
)
assert mgs == []
end
test "add member to multiple groups in single operation", %{actor: actor} do
{:ok, member} = Membership.create_member(%{email: "test@test.com"}, actor: actor)
{:ok, group1} = Membership.create_group(%{name: "Group One"}, actor: actor)
{:ok, group2} = Membership.create_group(%{name: "Group Two"}, actor: actor)
{:ok, group3} = Membership.create_group(%{name: "Group Three"}, actor: actor)
# Add to all groups
{:ok, _mg1} =
Membership.create_member_group(%{member_id: member.id, group_id: group1.id},
actor: actor
)
{:ok, _mg2} =
Membership.create_member_group(%{member_id: member.id, group_id: group2.id},
actor: actor
)
{:ok, _mg3} =
Membership.create_member_group(%{member_id: member.id, group_id: group3.id},
actor: actor
)
# Verify all associations exist
{:ok, member_with_groups} =
Ash.load(member, :groups, actor: actor, domain: Mv.Membership)
assert length(member_with_groups.groups) == 3
end
end
describe "Edge Cases" do
test "adding member to same group twice fails (duplicate prevention)", %{actor: actor} do
{:ok, member} = Membership.create_member(%{email: "test@test.com"}, actor: actor)
{:ok, group} = Membership.create_group(%{name: "Test Group"}, actor: actor)
{:ok, _mg1} =
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: actor
)
# Try to add again
assert {:error, %Ash.Error.Invalid{}} =
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: actor
)
end
test "removing member from group they're not in (idempotent, no error)", %{actor: actor} do
{:ok, member} = Membership.create_member(%{email: "test@test.com"}, actor: actor)
{:ok, group} = Membership.create_group(%{name: "Test Group"}, actor: actor)
# Verify no association exists
{:ok, nil} =
Ash.read_one(
Mv.Membership.MemberGroup
|> Ash.Query.filter(expr(member_id == ^member.id and group_id == ^group.id)),
actor: actor,
domain: Mv.Membership
)
# Test idempotency: Create association, delete it, then try to delete again
# This verifies that destroy_member_group is idempotent
{:ok, member_group} =
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: actor
)
# First deletion should succeed
assert :ok = Membership.destroy_member_group(member_group, actor: actor)
# Verify association is deleted
{:ok, nil} =
Ash.read_one(
Mv.Membership.MemberGroup
|> Ash.Query.filter(expr(id == ^member_group.id)),
actor: actor,
domain: Mv.Membership
)
# Try to destroy again - should be idempotent (either succeed or return not found error)
# Note: This tests the idempotency of the destroy action
result = Membership.destroy_member_group(member_group, actor: actor)
# Should either succeed (idempotent) or return an error (not found)
# Both behaviors are acceptable for idempotency
assert result == :ok || match?({:error, _}, result)
end
end
end

View file

@ -57,7 +57,7 @@ defmodule Mv.Helpers.SystemActorTest do
|> Ash.read_one(domain: Mv.Accounts, authorize?: false) do |> Ash.read_one(domain: Mv.Accounts, authorize?: false) do
{:ok, user} when not is_nil(user) -> {:ok, user} when not is_nil(user) ->
user user
|> Ash.Changeset.for_update(:update_internal, %{}) |> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!(authorize?: false) |> Ash.update!(authorize?: false)
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false) |> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
@ -68,7 +68,7 @@ defmodule Mv.Helpers.SystemActorTest do
upsert_identity: :unique_email, upsert_identity: :unique_email,
authorize?: false authorize?: false
) )
|> Ash.Changeset.for_update(:update_internal, %{}) |> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!(authorize?: false) |> Ash.update!(authorize?: false)
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false) |> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
@ -373,9 +373,9 @@ defmodule Mv.Helpers.SystemActorTest do
system_actor = SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
# Assign wrong role to system user (use :update_internal so bootstrap-style update is allowed) # Assign wrong role to system user
system_user system_user
|> Ash.Changeset.for_update(:update_internal, %{}) |> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, read_only_role, type: :append_and_remove) |> Ash.Changeset.manage_relationship(:role, read_only_role, type: :append_and_remove)
|> Ash.update!(actor: system_actor) |> Ash.update!(actor: system_actor)