defmodule Mv.Membership.JoinRequestTest do @moduledoc """ Tests for JoinRequest resource and public policies (Subtask 1: onboarding join concept). Covers: public create/read with actor nil, idempotency of confirm (confirmation_token_hash), and minimal required attributes. No framework behaviour is tested; only our policies and constraints. """ use Mv.DataCase, async: false alias Mv.Helpers.SystemActor alias Mv.Membership alias Mv.Membership.JoinRequest require Ash.Query # Client-only attributes for :confirm (server sets status, submitted_at, source, schema_version) defp valid_confirm_attrs(opts \\ []) do token = Keyword.get(opts, :confirmation_token_hash, "hash_#{System.unique_integer([:positive])}") [ email: "join_#{System.unique_integer([:positive])}@example.com", confirmation_token_hash: token, payload: %{} ] |> Enum.into(%{}) end describe "Public policies (actor: nil)" do test "confirm with actor nil succeeds" do attrs = valid_confirm_attrs() assert {:ok, %JoinRequest{} = request} = Membership.confirm_join_request(attrs, actor: nil) assert request.email == attrs.email assert request.status == "submitted" assert request.source == "public_join" end test "no public read: actor nil cannot read JoinRequest (by id or list)" do attrs = valid_confirm_attrs() {:ok, created} = Membership.confirm_join_request(attrs, actor: nil) get_result = Ash.get(JoinRequest, created.id, actor: nil, domain: Mv.Membership) assert match?({:error, %Ash.Error.Forbidden{}}, get_result) or match?( {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{}]}}, get_result ) list_result = JoinRequest |> Ash.read(actor: nil, domain: Mv.Membership) assert match?({:error, %Ash.Error.Forbidden{}}, list_result) or match?({:error, %Ash.Error.Invalid{}}, list_result) or list_result == {:ok, []}, "actor nil must not see any JoinRequests: got #{inspect(list_result)}" end test "generic create with actor nil is forbidden" do # Use full attrs required by :create so the only failure is policy, not validation attrs = valid_confirm_attrs() |> Map.merge(%{ status: "submitted", submitted_at: DateTime.utc_now(), source: "public_join", schema_version: 1 }) assert {:error, %Ash.Error.Forbidden{errors: [%Ash.Error.Forbidden.Policy{}]}} = JoinRequest |> Ash.Changeset.for_create(:create, attrs) |> Ash.create(actor: nil, domain: Mv.Membership) end end describe "Idempotency (confirmation_token_hash)" do test "second create with same confirmation_token_hash does not create duplicate" do system_actor = SystemActor.get_system_actor() token = "idempotent_token_#{System.unique_integer([:positive])}" attrs1 = valid_confirm_attrs(confirmation_token_hash: token) attrs2 = valid_confirm_attrs(confirmation_token_hash: token) attrs2 = %{attrs2 | email: "other_#{System.unique_integer([:positive])}@example.com"} assert {:ok, _first} = Membership.confirm_join_request(attrs1, actor: nil) # Second call with same token: idempotent return {:ok, nil} (no public read) assert {:ok, nil} = Membership.confirm_join_request(attrs2, actor: nil) # Count via allowed admin read (no authorize?: false) assert {:ok, list} = Membership.list_join_requests(actor: system_actor) count = Enum.count(list, &(&1.confirmation_token_hash == token)) assert count == 1, "expected exactly one JoinRequest with this confirmation_token_hash, got #{count}" end end describe "Resource and validations" do test "create with minimal required attributes succeeds" do attrs = valid_confirm_attrs() assert {:ok, %JoinRequest{}} = Membership.confirm_join_request(attrs, actor: nil) end test "email is required" do attrs = valid_confirm_attrs() |> Map.delete(:email) assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.confirm_join_request(attrs, actor: nil) assert Enum.any?(errors, fn e -> Map.get(e, :field) == :email end), "expected an error for field :email, got: #{inspect(errors)}" end end end