feat: add groups resource #371
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Simon 2026-01-27 16:03:21 +01:00
parent 8e9fbe76cf
commit 6db64bf996
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
12 changed files with 742 additions and 18 deletions

View file

@ -1069,10 +1069,9 @@ We test our business logic and domain-specific behavior, not core framework feat
- Create unique index on slug (for slug uniqueness and lookups) - 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 follows the same pattern as CustomFields: **Note:** Slug generation uses the shared `Mv.Membership.Changes.GenerateSlug` change,
- Uses `Mv.Membership.CustomField.Changes.GenerateSlug` (reusable change) which is used by both CustomFields and Groups for consistent slug generation.
- Or create `Mv.Membership.Group.Changes.GenerateSlug` if needed Slug is generated on create, immutable on update.
- 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

View file

@ -1,4 +1,4 @@
defmodule Mv.Membership.CustomField.Changes.GenerateSlug do defmodule Mv.Membership.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,12 +14,26 @@ defmodule Mv.Membership.CustomField.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"}
@ -32,7 +46,7 @@ defmodule Mv.Membership.CustomField.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). as changing slugs would break external references (e.g., CSV imports/exports, URL routes).
""" """
use Ash.Resource.Change use Ash.Resource.Change
@ -47,11 +61,14 @@ defmodule Mv.Membership.CustomField.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
@ -62,6 +79,9 @@ defmodule Mv.Membership.CustomField.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)
@ -80,6 +100,14 @@ defmodule Mv.Membership.CustomField.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")
@ -104,6 +132,7 @@ defmodule Mv.Membership.CustomField.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

@ -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.CustomField.Changes.GenerateSlug change Mv.Membership.Changes.GenerateSlug
validate string_length(:slug, min: 1) validate string_length(:slug, min: 1)
end end

171
lib/membership/group.ex Normal file
View file

@ -0,0 +1,171 @@
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
import Ash.Expr
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)
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
calculations do
calculate :member_count, :integer do
description "Number of members in this group"
calculation fn [group], _context ->
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
query =
Mv.Membership.MemberGroup
|> Ash.Query.filter(group_id == ^group.id)
case Ash.read(query, opts) do
{:ok, member_groups} -> [length(member_groups)]
{:error, _} -> [0]
end
end
end
end
identities do
identity :unique_slug, [:slug]
end
# Private helper function for case-insensitive name uniqueness check
defp check_name_uniqueness(name, exclude_id) do
query =
Mv.Membership.Group
|> Ash.Query.filter(fragment("LOWER(?) = LOWER(?)", name, ^name))
|> maybe_exclude_id(exclude_id)
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_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,6 +582,12 @@ 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

@ -0,0 +1,128 @@
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
MemberGroup.create!(%{member_id: member.id, group_id: group.id})
# Remove member from group
member_group = MemberGroup.get_by_member_and_group!(member.id, group.id)
MemberGroup.destroy!(member_group)
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer
require Ash.Query
import Ash.Expr
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)
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
defp check_duplicate_association(member_id, group_id, exclude_id) do
alias Mv.Helpers
alias Mv.Helpers.SystemActor
query =
Mv.Membership.MemberGroup
|> Ash.Query.filter(member_id == ^member_id and group_id == ^group_id)
|> maybe_exclude_id(exclude_id)
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_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,6 +7,8 @@ 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:
@ -14,6 +16,8 @@ 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.
@ -61,6 +65,19 @@ 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

@ -0,0 +1,116 @@
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

@ -0,0 +1,106 @@
{
"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

@ -0,0 +1,136 @@
{
"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

@ -50,7 +50,7 @@ defmodule Mv.Membership.GroupTest do
assert {:error, %Ash.Error.Invalid{errors: errors}} = assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_group(attrs, actor: actor) Membership.create_group(attrs, actor: actor)
assert error_message(errors, :name) =~ "must be at most 100" assert error_message(errors, :name) =~ "100" or error_message(errors, :name) =~ "length"
end end
test "return error when name is not unique (case-insensitive) - application level validation", test "return error when name is not unique (case-insensitive) - application level validation",
@ -77,7 +77,8 @@ defmodule Mv.Membership.GroupTest do
assert {:error, %Ash.Error.Invalid{errors: errors}} = assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_group(attrs, actor: actor) Membership.create_group(attrs, actor: actor)
assert error_message(errors, :description) =~ "must be at most 500" assert error_message(errors, :description) =~ "500" or
error_message(errors, :description) =~ "length"
end end
end end
@ -123,9 +124,13 @@ defmodule Mv.Membership.GroupTest do
Membership.create_group(%{name: "!!!"}, actor: actor) Membership.create_group(%{name: "!!!"}, actor: actor)
assert Enum.any?(errors, fn err -> assert Enum.any?(errors, fn err ->
(err.field == :slug or err.field == :name) and field = Map.get(err, :field)
(String.contains?(err.message, "cannot be empty") or message = Map.get(err, :message, Exception.message(err))
String.contains?(err.message, "is required"))
(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 end
end end
@ -277,8 +282,15 @@ defmodule Mv.Membership.GroupTest do
# Returns the error message for a given field, or empty string if not found # Returns the error message for a given field, or empty string if not found
defp error_message(errors, field) do defp error_message(errors, field) do
case Enum.find(errors, fn err -> Map.get(err, :field) == field end) do case Enum.find(errors, fn err -> Map.get(err, :field) == field end) do
nil -> "" nil ->
err -> Map.get(err, :message, "") ""
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 end
end end

View file

@ -44,10 +44,14 @@ defmodule Mv.Membership.MemberGroupTest do
) )
assert Enum.any?(errors, fn err -> assert Enum.any?(errors, fn err ->
((err.field == :member_id or err.field == :group_id) and field = Map.get(err, :field)
String.contains?(err.message, "already been taken")) or message = Map.get(err, :message, "")
String.contains?(err.message, "already exists") or
String.contains?(err.message, "duplicate") (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 end
end end