Add groups resource close #371 #378
5 changed files with 879 additions and 0 deletions
141
test/membership/group_database_constraints_test.exs
Normal file
141
test/membership/group_database_constraints_test.exs
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
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
|
||||
141
test/membership/group_integration_test.exs
Normal file
141
test/membership/group_integration_test.exs
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
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
|
||||
import Ash.Expr
|
||||
|
||||
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
|
||||
284
test/membership/group_test.exs
Normal file
284
test/membership/group_test.exs
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
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) =~ "must be at most 100"
|
||||
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) =~ "must be at most 500"
|
||||
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 ->
|
||||
(err.field == :slug or err.field == :name) and
|
||||
(String.contains?(err.message, "cannot be empty") or
|
||||
String.contains?(err.message, "is required"))
|
||||
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 -> Map.get(err, :message, "")
|
||||
end
|
||||
end
|
||||
end
|
||||
116
test/membership/member_group_test.exs
Normal file
116
test/membership/member_group_test.exs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
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 ->
|
||||
((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")
|
||||
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
|
||||
197
test/membership/member_groups_relationship_test.exs
Normal file
197
test/membership/member_groups_relationship_test.exs
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue