diff --git a/test/multitenancy_test.exs b/test/multitenancy_test.exs index 73f9cb52..cca48723 100644 --- a/test/multitenancy_test.exs +++ b/test/multitenancy_test.exs @@ -135,6 +135,214 @@ defmodule AshPostgres.Test.MultitenancyTest do assert [_] = CompositeKeyPost |> Ash.Query.set_tenant(org1) |> Ash.read!() end + test "aggregate validation prevents update with linked posts", %{org1: org1} do + # Create a post in org1 + post = + Post + |> Ash.Changeset.for_create(:create, %{name: "foo"}) + |> Ash.Changeset.set_tenant("org_" <> org1.id) + |> Ash.create!() + + # Create a linked post for the post in org1 + linked_post = + Post + |> Ash.Changeset.for_create(:create, %{name: "linked post"}) + |> Ash.Changeset.set_tenant("org_" <> org1.id) + |> Ash.create!() + + # Link the posts in org1 + post + |> Ash.Changeset.new() + |> Ash.Changeset.manage_relationship(:linked_posts, linked_post, type: :append_and_remove) + |> Ash.Changeset.set_tenant("org_" <> org1.id) + |> Ash.update!() + + # Test that aggregate validation works with tenant context + assert_raise Ash.Error.Invalid, ~r/Can only update if Post has no linked posts/, fn -> + post + |> Ash.Changeset.new() + |> Ash.Changeset.for_update(:update_if_no_linked_posts, %{name: "updated"}) + |> Ash.Changeset.set_tenant("org_" <> org1.id) + |> Ash.update!() + end + end + + test "non-atomic aggregate validation prevents update with linked posts", %{org1: org1} do + # Create a post in org1 + post = + Post + |> Ash.Changeset.for_create(:create, %{name: "foo"}) + |> Ash.Changeset.set_tenant("org_" <> org1.id) + |> Ash.create!() + + # Create a linked post for the post in org1 + linked_post = + Post + |> Ash.Changeset.for_create(:create, %{name: "linked post"}) + |> Ash.Changeset.set_tenant("org_" <> org1.id) + |> Ash.create!() + + # Link the posts in org1 + post + |> Ash.Changeset.new() + |> Ash.Changeset.manage_relationship(:linked_posts, linked_post, type: :append_and_remove) + |> Ash.Changeset.set_tenant("org_" <> org1.id) + |> Ash.update!() + + # Test non-atomic validation + assert_raise Ash.Error.Invalid, ~r/Can only update if Post has no linked posts/, fn -> + post + |> Ash.Changeset.new() + |> Ash.Changeset.for_update(:update_if_no_linked_posts_non_atomic, %{name: "updated"}) + |> Ash.Changeset.set_tenant("org_" <> org1.id) + |> Ash.update!() + end + end + + test "aggregate validation prevents destroy with linked posts", %{org1: org1} do + # Create a post in org1 + post = + Post + |> Ash.Changeset.for_create(:create, %{name: "foo"}) + |> Ash.Changeset.set_tenant("org_" <> org1.id) + |> Ash.create!() + + # Create a linked post for the post in org1 + linked_post = + Post + |> Ash.Changeset.for_create(:create, %{name: "linked post"}) + |> Ash.Changeset.set_tenant("org_" <> org1.id) + |> Ash.create!() + + # Link the posts in org1 + post + |> Ash.Changeset.new() + |> Ash.Changeset.manage_relationship(:linked_posts, linked_post, type: :append_and_remove) + |> Ash.Changeset.set_tenant("org_" <> org1.id) + |> Ash.update!() + + # Test destroy with atomic validation + assert_raise Ash.Error.Invalid, ~r/Can only delete if Post has no linked posts/, fn -> + post + |> Ash.Changeset.new() + |> Ash.Changeset.for_destroy(:destroy_if_no_linked_posts, %{}) + |> Ash.Changeset.set_tenant("org_" <> org1.id) + |> Ash.destroy!() + end + end + + test "non-atomic aggregate validation prevents destroy with linked posts", %{org1: org1} do + # Create a post in org1 + post = + Post + |> Ash.Changeset.for_create(:create, %{name: "foo"}) + |> Ash.Changeset.set_tenant("org_" <> org1.id) + |> Ash.create!() + + # Create a linked post for the post in org1 + linked_post = + Post + |> Ash.Changeset.for_create(:create, %{name: "linked post"}) + |> Ash.Changeset.set_tenant("org_" <> org1.id) + |> Ash.create!() + + # Link the posts in org1 + post + |> Ash.Changeset.new() + |> Ash.Changeset.manage_relationship(:linked_posts, linked_post, type: :append_and_remove) + |> Ash.Changeset.set_tenant("org_" <> org1.id) + |> Ash.update!() + + # Test destroy with non-atomic validation + assert_raise Ash.Error.Invalid, ~r/Can only delete if Post has no linked posts/, fn -> + post + |> Ash.Changeset.new() + |> Ash.Changeset.for_destroy(:destroy_if_no_linked_posts_non_atomic, %{}) + |> Ash.Changeset.set_tenant("org_" <> org1.id) + |> Ash.destroy!() + end + end + + test "post with no linked posts can be updated in another tenant", %{org1: org1, org2: org2} do + # Create a post in org1 with a linked post + post_in_org1 = + Post + |> Ash.Changeset.for_create(:create, %{name: "foo"}) + |> Ash.Changeset.set_tenant("org_" <> org1.id) + |> Ash.create!() + + linked_post = + Post + |> Ash.Changeset.for_create(:create, %{name: "linked post"}) + |> Ash.Changeset.set_tenant("org_" <> org1.id) + |> Ash.create!() + + post_in_org1 + |> Ash.Changeset.new() + |> Ash.Changeset.manage_relationship(:linked_posts, linked_post, type: :append_and_remove) + |> Ash.Changeset.set_tenant("org_" <> org1.id) + |> Ash.update!() + + # Create a post in org2 with no linked posts + org2_post = + Post + |> Ash.Changeset.for_create(:create, %{name: "updateable"}) + |> Ash.Changeset.set_tenant("org_" <> org2.id) + |> Ash.create!() + + # This should succeed since the post has no linked posts in org2 + updated_post = + org2_post + |> Ash.Changeset.new() + |> Ash.Changeset.for_update(:update_if_no_linked_posts, %{name: "updated"}) + |> Ash.Changeset.set_tenant("org_" <> org2.id) + |> Ash.update!() + + assert updated_post.name == "updated" + end + + test "post with no linked posts can be destroyed in another tenant", %{org1: org1, org2: org2} do + # Create a post in org1 with a linked post + post_in_org1 = + Post + |> Ash.Changeset.for_create(:create, %{name: "foo"}) + |> Ash.Changeset.set_tenant("org_" <> org1.id) + |> Ash.create!() + + linked_post = + Post + |> Ash.Changeset.for_create(:create, %{name: "linked post"}) + |> Ash.Changeset.set_tenant("org_" <> org1.id) + |> Ash.create!() + + post_in_org1 + |> Ash.Changeset.new() + |> Ash.Changeset.manage_relationship(:linked_posts, linked_post, type: :append_and_remove) + |> Ash.Changeset.set_tenant("org_" <> org1.id) + |> Ash.update!() + + # Create a post in org2 with no linked posts + org2_post_for_destroy = + Post + |> Ash.Changeset.for_create(:create, %{name: "destroyable"}) + |> Ash.Changeset.set_tenant("org_" <> org2.id) + |> Ash.create!() + + # This should succeed since the post has no linked posts in org2 + org2_post_for_destroy + |> Ash.Changeset.new() + |> Ash.Changeset.for_destroy(:destroy_if_no_linked_posts, %{}) + |> Ash.Changeset.set_tenant("org_" <> org2.id) + |> Ash.destroy!() + + # Verify the post was destroyed + assert [] = + Post + |> Ash.Query.filter(id == ^org2_post_for_destroy.id) + |> Ash.Query.set_tenant("org_" <> org2.id) + |> Ash.read!() + end + test "loading attribute multitenant resources from context multitenant resources works" do org = Org diff --git a/test/support/multitenancy/resources/post.ex b/test/support/multitenancy/resources/post.ex index e097fffb..2b494223 100644 --- a/test/support/multitenancy/resources/post.ex +++ b/test/support/multitenancy/resources/post.ex @@ -1,3 +1,21 @@ +defmodule HasNoLinkedPosts do + @moduledoc false + use Ash.Resource.Validation + + def atomic(_changeset, _opts, context) do + condition = expr(exists(linked_posts, true)) + + [ + {:atomic, [], condition, + expr( + error(^Ash.Error.Changes.InvalidChanges, %{ + message: ^context.message || "Post has linked posts" + }) + )} + ] + end +end + defmodule AshPostgres.MultitenancyTest.Post do @moduledoc false use Ash.Resource, @@ -27,6 +45,34 @@ defmodule AshPostgres.MultitenancyTest.Post do defaults([:create, :read, :update, :destroy]) update(:update_with_policy) + + update :update_if_no_linked_posts do + validate HasNoLinkedPosts do + message "Can only update if Post has no linked posts" + end + end + + update :update_if_no_linked_posts_non_atomic do + require_atomic?(false) + + validate HasNoLinkedPosts do + message "Can only update if Post has no linked posts" + end + end + + destroy :destroy_if_no_linked_posts do + validate HasNoLinkedPosts do + message "Can only delete if Post has no linked posts" + end + end + + destroy :destroy_if_no_linked_posts_non_atomic do + require_atomic?(false) + + validate HasNoLinkedPosts do + message "Can only delete if Post has no linked posts" + end + end end postgres do