diff --git a/test/membership/group_database_constraints_test.exs b/test/membership/group_database_constraints_test.exs new file mode 100644 index 0000000..4418b33 --- /dev/null +++ b/test/membership/group_database_constraints_test.exs @@ -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 diff --git a/test/membership/group_integration_test.exs b/test/membership/group_integration_test.exs new file mode 100644 index 0000000..7664660 --- /dev/null +++ b/test/membership/group_integration_test.exs @@ -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 diff --git a/test/membership/group_test.exs b/test/membership/group_test.exs new file mode 100644 index 0000000..a9e058a --- /dev/null +++ b/test/membership/group_test.exs @@ -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 diff --git a/test/membership/member_group_test.exs b/test/membership/member_group_test.exs new file mode 100644 index 0000000..54f9ff9 --- /dev/null +++ b/test/membership/member_group_test.exs @@ -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 diff --git a/test/membership/member_groups_relationship_test.exs b/test/membership/member_groups_relationship_test.exs new file mode 100644 index 0000000..a72c8bc --- /dev/null +++ b/test/membership/member_groups_relationship_test.exs @@ -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