diff --git a/test/membership/join_request_test.exs b/test/membership/join_request_test.exs new file mode 100644 index 0000000..1d8ff95 --- /dev/null +++ b/test/membership/join_request_test.exs @@ -0,0 +1,114 @@ +defmodule Mv.Membership.JoinRequestTest do + @moduledoc """ + Tests for JoinRequest resource (public join flow, Subtask 1). + + Covers: submit and confirm actions with actor: nil, validations, idempotent confirm, + and policy that non-public actions (e.g. read) are Forbidden with actor: nil. + No UI or email; resource and persistence only. + + Requires: Mv.Membership.JoinRequest resource and domain functions + submit_join_request/2, confirm_join_request/2. Policy test requires the resource + to be loaded; unskip when JoinRequest exists. + """ + use Mv.DataCase, async: true + + alias Mv.Membership + + # Valid minimal attributes for submit (email required; confirmation_token optional for tests) + @valid_submit_attrs %{ + email: "join#{System.unique_integer([:positive])}@example.com" + } + + describe "submit_join_request/2 (create with actor: nil)" do + test "creates JoinRequest in pending_confirmation with valid attributes and actor nil" do + attrs = Map.put(@valid_submit_attrs, :confirmation_token, "test-token-#{System.unique_integer([:positive])}") + + assert {:ok, request} = + Membership.submit_join_request(attrs, actor: nil) + + assert request.status == :pending_confirmation + assert request.email == attrs.email + assert request.confirmation_token_hash != nil + assert request.confirmation_token_expires_at != nil + end + + test "persists first_name, last_name and form_data when provided" do + attrs = + @valid_submit_attrs + |> Map.put(:confirmation_token, "token-#{System.unique_integer([:positive])}") + |> Map.put(:first_name, "Jane") + |> Map.put(:last_name, "Doe") + |> Map.put(:form_data, %{"city" => "Berlin", "notes" => "Hello"}) + |> Map.put(:schema_version, 1) + + assert {:ok, request} = + Membership.submit_join_request(attrs, actor: nil) + + assert request.first_name == "Jane" + assert request.last_name == "Doe" + assert request.form_data == %{"city" => "Berlin", "notes" => "Hello"} + assert request.schema_version == 1 + end + + test "returns validation error when email is missing" do + attrs = %{first_name: "Test"} |> Map.delete(:email) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = + Membership.submit_join_request(attrs, actor: nil) + + assert error_message(errors, :email) =~ "must be present" + end + end + + describe "confirm_join_request/2 (update with actor: nil)" do + test "updates JoinRequest to submitted when given valid token and actor nil" do + token = "confirm-token-#{System.unique_integer([:positive])}" + attrs = Map.put(@valid_submit_attrs, :confirmation_token, token) + + {:ok, request_before} = Membership.submit_join_request(attrs, actor: nil) + assert request_before.status == :pending_confirmation + + assert {:ok, request_after} = + Membership.confirm_join_request(token, actor: nil) + + assert request_after.id == request_before.id + assert request_after.status == :submitted + assert request_after.submitted_at != nil + end + + test "confirm is idempotent when called twice with same token" do + token = "idempotent-token-#{System.unique_integer([:positive])}" + attrs = Map.put(@valid_submit_attrs, :confirmation_token, token) + + {:ok, _} = Membership.submit_join_request(attrs, actor: nil) + {:ok, first} = Membership.confirm_join_request(token, actor: nil) + submitted_at = first.submitted_at + + assert {:ok, second} = Membership.confirm_join_request(token, actor: nil) + assert second.status == :submitted + assert second.submitted_at == submitted_at + end + + test "returns error when token is unknown or invalid" do + assert {:error, _} = Membership.confirm_join_request("nonexistent-token", actor: nil) + end + + @tag :skip + test "returns error when token is expired (requires fixture for expired token)" + end + + describe "policies (actor: nil)" do + @tag :skip + test "read with actor nil returns Forbidden (unskip and add: Ash.read(Mv.Membership.JoinRequest, actor: nil, domain: Mv.Membership) -> expect Forbidden)" do + # When JoinRequest resource exists: assert {:error, %Ash.Error.Forbidden{}} = Ash.read(Mv.Membership.JoinRequest, actor: nil, domain: Mv.Membership) + flunk("Add JoinRequest resource, then unskip and replace this with the Ash.read assertion") + end + end + + defp error_message(errors, field) do + errors + |> Enum.filter(fn err -> Map.get(err, :field) == field end) + |> Enum.map(&Map.get(&1, :message, "")) + |> List.first() + end +end