From 6db64bf996599ff3281e6c6bc97f59a27cd584f8 Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 27 Jan 2026 16:03:21 +0100 Subject: [PATCH] feat: add groups resource #371 --- docs/groups-architecture.md | 7 +- .../changes/generate_slug.ex | 33 +++- lib/membership/custom_field.ex | 2 +- lib/membership/group.ex | 171 ++++++++++++++++++ lib/membership/member.ex | 6 + lib/membership/member_group.ex | 128 +++++++++++++ lib/membership/membership.ex | 17 ++ ...127141620_add_groups_and_member_groups.exs | 116 ++++++++++++ .../repo/groups/20260127141620.json | 106 +++++++++++ .../repo/member_groups/20260127141620.json | 136 ++++++++++++++ test/membership/group_test.exs | 26 ++- test/membership/member_group_test.exs | 12 +- 12 files changed, 742 insertions(+), 18 deletions(-) rename lib/membership/{custom_field => }/changes/generate_slug.ex (80%) create mode 100644 lib/membership/group.ex create mode 100644 lib/membership/member_group.ex create mode 100644 priv/repo/migrations/20260127141620_add_groups_and_member_groups.exs create mode 100644 priv/resource_snapshots/repo/groups/20260127141620.json create mode 100644 priv/resource_snapshots/repo/member_groups/20260127141620.json diff --git a/docs/groups-architecture.md b/docs/groups-architecture.md index 5e31bc5..023df5b 100644 --- a/docs/groups-architecture.md +++ b/docs/groups-architecture.md @@ -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 index on lowercased name for search -**Note:** Slug generation follows the same pattern as CustomFields: -- Uses `Mv.Membership.CustomField.Changes.GenerateSlug` (reusable change) -- Or create `Mv.Membership.Group.Changes.GenerateSlug` if needed -- Slug is generated on create, immutable on update +**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** - Create table with UUID v7 primary key diff --git a/lib/membership/custom_field/changes/generate_slug.ex b/lib/membership/changes/generate_slug.ex similarity index 80% rename from lib/membership/custom_field/changes/generate_slug.ex rename to lib/membership/changes/generate_slug.ex index 061d7e7..d9d2216 100644 --- a/lib/membership/custom_field/changes/generate_slug.ex +++ b/lib/membership/changes/generate_slug.ex @@ -1,4 +1,4 @@ -defmodule Mv.Membership.CustomField.Changes.GenerateSlug do +defmodule Mv.Membership.Changes.GenerateSlug do @moduledoc """ 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 - 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 # Create with automatic slug generation CustomField.create!(%{name: "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 CustomField.create!(%{name: "Café Müller"}) # => %CustomField{name: "Café Müller", slug: "cafe-muller"} @@ -32,7 +46,7 @@ defmodule Mv.Membership.CustomField.Changes.GenerateSlug do ## Implementation Note 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 @@ -47,11 +61,14 @@ defmodule Mv.Membership.CustomField.Changes.GenerateSlug do ## Parameters - `changeset` - The Ash changeset + - `_opts` - Options passed to the change (unused) + - `_context` - Ash context map (unused) ## Returns The changeset with the `:slug` attribute set to the generated slug. """ + @impl true def change(changeset, _opts, _context) do # Only generate slug on create, not on update (immutability) if changeset.action_type == :create do @@ -62,6 +79,9 @@ defmodule Mv.Membership.CustomField.Changes.GenerateSlug do name when is_binary(name) -> slug = generate_slug(name) Ash.Changeset.force_change_attribute(changeset, :slug, slug) + + _ -> + changeset end else # On update, don't touch the slug (immutable) @@ -80,6 +100,14 @@ defmodule Mv.Membership.CustomField.Changes.GenerateSlug do - Leading/trailing hyphens removed - 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 iex> generate_slug("Mobile Phone") @@ -104,6 +132,7 @@ defmodule Mv.Membership.CustomField.Changes.GenerateSlug do "strasse" """ + @spec generate_slug(String.t()) :: String.t() def generate_slug(name) when is_binary(name) do slug = Slug.slugify(name) diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index 18b8154..94cb657 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -63,7 +63,7 @@ defmodule Mv.Membership.CustomField do create :create do 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) end diff --git a/lib/membership/group.ex b/lib/membership/group.ex new file mode 100644 index 0000000..9670c5e --- /dev/null +++ b/lib/membership/group.ex @@ -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 diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 1a5d805..da61146 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -582,6 +582,12 @@ defmodule Mv.Membership.Member do # has_many: All fee cycles for this member 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 calculations do diff --git a/lib/membership/member_group.ex b/lib/membership/member_group.ex new file mode 100644 index 0000000..551531d --- /dev/null +++ b/lib/membership/member_group.ex @@ -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 diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 97ac4be..b42daa5 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -7,6 +7,8 @@ defmodule Mv.Membership do - `CustomFieldValue` - Dynamic custom field values attached to members - `CustomField` - Schema definitions for custom fields - `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 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 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` + - 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 The domain is configured with AshAdmin for management UI. @@ -61,6 +65,19 @@ defmodule Mv.Membership do define :update_single_member_field_visibility, action: :update_single_member_field_visibility 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 # Singleton pattern: Get the single settings record diff --git a/priv/repo/migrations/20260127141620_add_groups_and_member_groups.exs b/priv/repo/migrations/20260127141620_add_groups_and_member_groups.exs new file mode 100644 index 0000000..3736956 --- /dev/null +++ b/priv/repo/migrations/20260127141620_add_groups_and_member_groups.exs @@ -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 diff --git a/priv/resource_snapshots/repo/groups/20260127141620.json b/priv/resource_snapshots/repo/groups/20260127141620.json new file mode 100644 index 0000000..ade6dd8 --- /dev/null +++ b/priv/resource_snapshots/repo/groups/20260127141620.json @@ -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" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/member_groups/20260127141620.json b/priv/resource_snapshots/repo/member_groups/20260127141620.json new file mode 100644 index 0000000..957c84c --- /dev/null +++ b/priv/resource_snapshots/repo/member_groups/20260127141620.json @@ -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" +} \ No newline at end of file diff --git a/test/membership/group_test.exs b/test/membership/group_test.exs index a9e058a..1c84eeb 100644 --- a/test/membership/group_test.exs +++ b/test/membership/group_test.exs @@ -50,7 +50,7 @@ defmodule Mv.Membership.GroupTest do assert {:error, %Ash.Error.Invalid{errors: errors}} = 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 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}} = 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 @@ -123,9 +124,13 @@ defmodule Mv.Membership.GroupTest do Membership.create_group(%{name: "!!!"}, actor: actor) assert Enum.any?(errors, fn err -> - (err.field == :slug or err.field == :name) and - (String.contains?(err.message, "cannot be empty") or - String.contains?(err.message, "is required")) + 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 @@ -277,8 +282,15 @@ defmodule Mv.Membership.GroupTest do # 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 -> Map.get(err, :message, "") + 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 diff --git a/test/membership/member_group_test.exs b/test/membership/member_group_test.exs index 54f9ff9..b3c048f 100644 --- a/test/membership/member_group_test.exs +++ b/test/membership/member_group_test.exs @@ -44,10 +44,14 @@ defmodule Mv.Membership.MemberGroupTest do ) assert Enum.any?(errors, fn err -> - ((err.field == :member_id or err.field == :group_id) and - String.contains?(err.message, "already been taken")) or - String.contains?(err.message, "already exists") or - String.contains?(err.message, "duplicate") + 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