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 # Allowlist must include custom fields so FilterFormDataByAllowlist persists them {:ok, settings} = Membership.get_settings() Mv.Membership.update_settings(settings, %{ join_form_enabled: true, join_form_field_ids: ["email", "first_name", "last_name", "city", "notes"], join_form_field_required: %{ "email" => true, "first_name" => false, "last_name" => false, "city" => false, "notes" => false } }) 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 test "returns error when token is expired" do token = "expired-token-#{System.unique_integer([:positive])}" attrs = Map.put(@valid_submit_attrs, :confirmation_token, token) {:ok, request} = Membership.submit_join_request(attrs, actor: nil) past = DateTime.add(DateTime.utc_now(), -1, :hour) id_binary = Ecto.UUID.dump!(request.id) from(j in "join_requests", where: fragment("id = ?", ^id_binary)) |> Repo.update_all(set: [confirmation_token_expires_at: past]) assert {:error, :token_expired} = Membership.confirm_join_request(token, actor: nil) end end describe "policies (actor: nil)" do test "read with actor nil returns Forbidden" do assert {:error, %Ash.Error.Forbidden{}} = Ash.read(Mv.Membership.JoinRequest, actor: nil, domain: Mv.Membership) end end describe "allowlist (server-side field filter)" do test "submit with non-allowlisted form_data keys does not persist those keys" do # Allowlist restricts which fields are accepted; extra keys must not be stored. {:ok, settings} = Membership.get_settings() Mv.Membership.update_settings(settings, %{ join_form_enabled: true, join_form_field_ids: ["email", "first_name", "city"], join_form_field_required: %{"email" => true, "first_name" => false, "city" => false} }) attrs = %{ email: "allowlist#{System.unique_integer([:positive])}@example.com", first_name: "Allowed", confirmation_token: "tok-#{System.unique_integer([:positive])}", form_data: %{"city" => "Berlin", "internal_or_secret" => "must not persist"}, schema_version: 1 } assert {:ok, request} = Membership.submit_join_request(attrs, actor: nil) assert request.email == attrs.email assert request.first_name == attrs.first_name refute Map.has_key?(request.form_data || %{}, "internal_or_secret") assert (request.form_data || %{})["city"] == "Berlin" 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