test: add tests for approval ui

This commit is contained in:
Simon 2026-03-10 23:21:57 +01:00
parent 021b709e6a
commit 50433e607f
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
6 changed files with 466 additions and 11 deletions

View file

@ -1,6 +1,6 @@
# Onboarding & Join High-Level Concept
**Status:** Draft for design decisions and implementation specs
**Status:** Draft for design decisions and implementation specs. **Prio 1 (Subtasks 14) implemented.**
**Scope:** Prio 1 = public Join form; Step 2 = Vorstand approval. Invite-Link and OIDC JIT are out of scope and documented only as future entry paths.
**Related:** Issue #308, roles-and-permissions-architecture, page-permission-route-coverage.
@ -102,10 +102,56 @@
## 3. Step 2: Vorstand Approval
- **Goal:** Board (Vorstand) can review join requests (e.g. list status "submitted") and approve or reject.
- **Route:** **`/join_requests`** for the approval UI (list and detail). See §3.1 for full specification.
- **Outcome of approval (admin-configurable):**
- **Default:** Approval creates **Member only**; no User is created. An admin can link a User later if needed.
- **Optional (configurable):** If an option is set, approval may also create a **User** (e.g. invite-to-set-password). This is **open for later**; implementation concepts will be detailed when that option is implemented.
- **Permissions:** Approval uses the existing permission set **normal_user** (e.g. role "Kassenwart"). JoinRequest gets read and update (or dedicated approve/reject actions) for scope :all in normal_user, and the approval page (e.g. `/join_requests` or `/onboarding/join_requests`) is added to normal_users allowed pages.
- **Permissions:** Approval uses the existing permission set **normal_user** (e.g. role "Kassenwart"). JoinRequest gets read and update (or dedicated approve/reject actions) for scope :all in normal_user, and **`/join_requests`** (and **`/join_requests/:id`** for detail) are added to normal_users allowed pages.
### 3.1 Step 2 Approval (detail)
Implementation spec for Subtask 5.
#### Route and pages
- **List:** **`/join_requests`** list of join requests. Filter by status (default or primary view: status `submitted`); optional view for "all" or "approved/rejected" for audit.
- **Detail:** **`/join_requests/:id`** single join request with all data (typed fields + `form_data`), actions Approve / Reject.
#### Backend (JoinRequest)
- **New actions (authenticated only):**
- **`approve`** (update): allowed only when status is `submitted`. Sets status `approved`, `approved_at`, `reviewed_by_user_id` (actor). Triggers promotion to Member (see Promotion below).
- **`reject`** (update): allowed only when status is `submitted`. Sets status `rejected`, `rejected_at`, `reviewed_by_user_id`. No reason field in MVP.
- **Policies:** `approve` and `reject` permitted via **HasPermission** for permission set **normal_user** (read/update or explicit approve/reject on JoinRequest, scope :all). Not allowed for `actor: nil`.
- **Domain:** Expose `list_join_requests/1` (e.g. filter by status, with actor), `approve_join_request/2` (id, actor), `reject_join_request/2` (id, actor). Read action for JoinRequest for normal_user scope :all so list/detail can load data.
#### Promotion: JoinRequest → Member
- **When:** On successful `approve` only (status was `submitted`).
- **Mapping:**
- JoinRequest typed fields → Member: **email**, **first_name**, **last_name** copied to Member attributes.
- **form_data** (jsonb): keys that match `Mv.Constants.member_fields()` (atom names or string keys) → corresponding Member attributes. Keys that are custom field IDs (UUID format) → create **CustomFieldValue** records linked to the new Member.
- **Defaults:** e.g. `join_date` = today if not in form_data; `membership_fee_type_id` = default from settings (or first available) if not provided. Handle required Member validations (e.g. email already present from JoinRequest).
- **Implementation:** Prefer a single Ash change (e.g. `JoinRequest.Changes.PromoteToMember`) or a domain function that builds member attributes + custom_field_values from the approved JoinRequest and calls Member `create_member` (actor: reviewer or system actor as per CODE_GUIDELINES; document choice). No User created in MVP.
- **Idempotency:** If approve is called again by mistake (e.g. race), either reject transition when status is already `approved` or ensure Member creation is idempotent (e.g. do not create duplicate Member for same JoinRequest).
#### Permission sets and routing
- **PermissionSets (normal_user):** Add JoinRequest **read** :all and **update** :all (or **approve** / **reject** if using dedicated actions). Add pages **`/join_requests`** and **`/join_requests/:id`** to the normal_user pages list.
- **Router:** Register live routes for list and detail; add entries to **page-permission-route-coverage.md** and extend plug tests so normal_user is allowed, read_only/own_data denied.
#### UI/UX (approval)
- **List:** Table or card list with columns e.g. submitted_at, first_name, last_name, email, status. Primary filter or default filter: status = `submitted`. Buttons or links to open detail. Follow existing list patterns (e.g. Members/Groups): header, back link, CoreComponents table.
- **Detail:** Show all request data (typed + form_data rendered by field). Buttons: **Approve** (primary), **Reject** (secondary). Reject in MVP: no reason field; just set status to rejected and audit fields.
- **Accessibility and i18n:** Same standards as rest of app (labels, Gettext, keyboard, ARIA where needed).
#### Tests
- JoinRequest: policy tests approve/reject allowed for normal_user (and admin), forbidden for nil/own_data/read_only.
- Domain: approve creates one Member with correct mapped data; reject only updates status and audit fields; approve when already approved is no-op or error.
- Page permission: normal_user can GET `/join_requests` and `/join_requests/:id`; read_only/own_data cannot.
- Optional: LiveView smoke test list loads, approve/reject from detail updates state.
---
@ -153,6 +199,7 @@
- **Rate limiting:** Honeypot + rate limiting from the start (e.g. Hammer.Plug).
- **Settings:** Own section "Onboarding / Join" in global settings; `join_form_enabled` plus field selection; display as list/badges; detailed UX in a **separate subtask**.
- **Approval permission:** normal_user; JoinRequest read/update and approval page added to normal_user; no new permission set.
- **Approval route:** **`/join_requests`** (list) and **`/join_requests/:id`** (detail).
- **Resend confirmation:** If not in Prio 1, create a separate ticket immediately.
**Open for later:**
@ -180,26 +227,26 @@
### Prio 1 Public Join (4 subtasks)
#### 1. JoinRequest resource and public policies
#### 1. JoinRequest resource and public policies **(done)**
- **Scope:** Ash resource `JoinRequest` per §2.3.2: status (`pending_confirmation`, `submitted`, `approved`, `rejected`), email (required), first_name, last_name (optional), form_data (jsonb), schema_version; confirmation_token_hash, confirmation_token_expires_at; submitted_at, approved_at, rejected_at, reviewed_by_user_id, source. Migration; unique_index on confirmation_token_hash (or equivalent for idempotency).
- **Policies:** Public actions **submit** (create) and **confirm** (update) allowed with `actor: nil`; no system-actor fallback, no undocumented `authorize?: false`.
- **Boundary:** No UI, no emails only resource, persistence, and actions callable with nil actor.
- **Done:** Resource and migration in place; tests for create/update with `actor: nil` and for idempotent confirm (same token twice → no second update).
#### 2. Submit and confirm flow
#### 2. Submit and confirm flow **(done)**
- **Scope:** Form submit → **create** JoinRequest (status `pending_confirmation`, token hash + expiry, form data) → send confirmation email (reuse AshAuthentication sender pattern). Route **`/confirm_join/:token`** → verify token (hash and lookup) → **update** JoinRequest to status `submitted`, set submitted_at, invalidate token (idempotent if already submitted). Optional: Oban (or similar) job to **hard-delete** JoinRequests in `pending_confirmation` with confirmation_token_expires_at older than 24h.
- **Boundary:** No join-form UI, no admin settings only backend create/update and email/route.
- **Done:** Submit creates one JoinRequest; confirm updates it to submitted; double-click idempotent; expired token shows clear message; cleanup job implemented and documented. Tests for these cases.
#### 3. Admin: Join form settings
#### 3. Admin: Join form settings **(done)**
- **Scope:** Section "Onboarding / Join" in global settings (§2.6): `join_form_enabled`, selection of join-form fields (from member_fields + custom fields), "required" per field. Persist (e.g. Setting or existing config). UI e.g. badges with remove + dropdown/modal to add (details in sub-subtask if needed).
- **Boundary:** No public form only save/load of config and **server-side allowlist** for use in subtask 4.
- **Done:** Settings save/load; allowlist available in backend for join form; tests.
#### 4. Public join page and anti-abuse
#### 4. Public join page and anti-abuse **(done)**
- **Scope:** Route **`/join`** (public). **Add `/join` to the page-permission plugs public path list** so unauthenticated access is allowed. LiveView (or controller + form). Form fields from allowlist (subtask 3); copy per §2.5. **Honeypot** and **rate limiting** (e.g. Hammer.Plug) on join/submit. After submit: show "We have saved your details … click the link …". Expired-link page: clear message + "submit form again". Public-path tests updated to include `/join`.
- **Boundary:** No approval UI, no User/Member creation only public page, form, anti-abuse, and wiring to submit/confirm flow (subtask 2).
@ -215,7 +262,8 @@
#### 5. Approval UI (Vorstand)
- **Scope:** List JoinRequests (status "submitted"), approve/reject actions; on approve create Member (no User in MVP). Permission: normal_user; add page to PermissionSets. Populate audit fields (approved_at, rejected_at, reviewed_by_user_id).
- **Route:** **`/join_requests`** (list), **`/join_requests/:id`** (detail). Full specification: §3.1.
- **Scope:** List JoinRequests (status "submitted"), approve/reject actions; on approve create Member (no User in MVP). Permission: normal_user; add JoinRequest read/update (or approve/reject) and pages `/join_requests`, `/join_requests/:id` to PermissionSets. Populate audit fields (approved_at, rejected_at, reviewed_by_user_id). Promotion: JoinRequest → Member per §3.1 (mapping, defaults, idempotency).
- **Boundary:** Separate ticket; builds on JoinRequest and existing Member creation.
---
@ -223,7 +271,7 @@
## 9. References
- `docs/roles-and-permissions-architecture.md` Permission sets, roles, page permissions.
- `docs/page-permission-route-coverage.md` Public paths, plug behaviour, tests.
- `docs/page-permission-route-coverage.md` Public paths, plug behaviour, tests; add `/join_requests` and `/join_requests/:id` for Step 2 (normal_user).
- `lib/mv_web/plugs/check_page_permission.ex` Public path list; **add `/join`** in `public_path?/1`.
- `lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex` Existing confirmation-email pattern (token, link, Mailer).
- Hammer / Hammer.Plug (e.g. hexdocs.pm/hammer) Rate limiting for Phoenix/Plug.

View file

@ -31,8 +31,10 @@ This document lists all protected routes, which permission set may access them,
| `/admin/roles/new` | ✗ | ✗ | ✗ | ✓ |
| `/admin/roles/:id` | ✗ | ✗ | ✗ | ✓ |
| `/admin/roles/:id/edit` | ✗ | ✗ | ✗ | ✓ |
| `/join_requests` (Step 2) | ✗ | ✗ | ✓ | ✓ |
| `/join_requests/:id` (Step 2) | ✗ | ✗ | ✓ | ✓ |
**Note:** Permission sets define `/custom_field_values` and related paths, but there are no such routes in the router; those entries are for future use.
**Note:** Permission sets define `/custom_field_values` and related paths, but there are no such routes in the router; those entries are for future use. Step 2 (Approval UI) adds `/join_requests` and `/join_requests/:id` for normal_user and admin; routes and permission set entries are not yet implemented; tests exist in `check_page_permission_test.exs` (describe "join_requests routes" and integration blocks).
## Public Paths (no permission check)
@ -55,6 +57,7 @@ The join confirmation route `GET /confirm_join/:token` is public (matched by `/c
- Unauthenticated: nil user denied, redirect `/sign-in`.
- Public: unauthenticated allowed `/auth/sign-in`, `/register`.
- Error: no role, invalid permission_set_name → denied.
- **Join requests (Step 2):** normal_user and admin allowed `/join_requests`, `/join_requests/:id`; read_only and own_data denied. Tests fail (red) until routes and permission set are added.
### Integration tests (full router, Mitglied = own_data)

View file

@ -0,0 +1,139 @@
defmodule Mv.Membership.JoinRequestApprovalDomainTest do
@moduledoc """
Domain tests for JoinRequest approval: approve/reject and promotion to Member (Step 2).
Asserts that approve creates one Member with mapped data, reject does not create Member,
status rules, and idempotency. No User creation in MVP.
"""
use Mv.DataCase, async: true
import Ash.Expr
require Ash.Query
alias Mv.Fixtures
alias Mv.Helpers.SystemActor
alias Mv.Membership
alias Mv.Membership.Member
defp member_count do
actor = SystemActor.get_system_actor()
{:ok, members} = Membership.list_members(actor: actor)
length(members)
end
describe "approve_join_request/2 promotion to Member" do
test "approve creates exactly one member with email, first_name, last_name from JoinRequest" do
request =
Fixtures.submitted_join_request_fixture(%{
first_name: "Approved",
last_name: "User"
})
count_before = member_count()
user = Fixtures.user_with_role_fixture("normal_user")
assert {:ok, approved} = Membership.approve_join_request(request.id, actor: user)
assert approved.status == :approved
assert member_count() == count_before + 1
request_email = request.email
[member] =
Member
|> Ash.Query.filter(expr(^ref(:email) == ^request_email))
|> Ash.read!(actor: SystemActor.get_system_actor(), domain: Membership)
assert member.email == request.email
assert member.first_name == request.first_name
assert member.last_name == request.last_name
end
test "approve does not create a User (MVP)" do
request = Fixtures.submitted_join_request_fixture()
user = Fixtures.user_with_role_fixture("normal_user")
assert {:ok, _} = Membership.approve_join_request(request.id, actor: user)
# No User should exist with this email from the approval flow
request_email = request.email
users_with_email =
Mv.Accounts.User
|> Ash.Query.filter(expr(^ref(:email) == ^request_email))
|> Ash.read!(authorize?: false)
assert users_with_email == []
end
end
describe "reject_join_request/2" do
test "reject does not create a member" do
request = Fixtures.submitted_join_request_fixture()
count_before = member_count()
user = Fixtures.user_with_role_fixture("normal_user")
assert {:ok, rejected} = Membership.reject_join_request(request.id, actor: user)
assert rejected.status == :rejected
assert rejected.rejected_at != nil
assert member_count() == count_before
end
end
describe "approve_join_request/2 status and idempotency" do
test "approve when status is already approved is idempotent or returns error" do
request = Fixtures.submitted_join_request_fixture()
user = Fixtures.user_with_role_fixture("normal_user")
assert {:ok, _} = Membership.approve_join_request(request.id, actor: user)
count_after_first = member_count()
# Second approve: either {:ok, request} with no duplicate member, or {:error, _}
result = Membership.approve_join_request(request.id, actor: user)
if match?({:ok, _}, result) do
assert member_count() == count_after_first
else
assert {:error, _} = result
end
end
test "approve when status is pending_confirmation returns error" do
token = "pending-token-#{System.unique_integer([:positive])}"
attrs = %{
email: "pending#{System.unique_integer([:positive])}@example.com",
confirmation_token: token
}
{:ok, request} = Membership.submit_join_request(attrs, actor: nil)
assert request.status == :pending_confirmation
user = Fixtures.user_with_role_fixture("normal_user")
assert {:error, _} = Membership.approve_join_request(request.id, actor: user)
end
test "approve when status is rejected returns error" do
request = Fixtures.submitted_join_request_fixture()
user = Fixtures.user_with_role_fixture("normal_user")
assert {:ok, _} = Membership.reject_join_request(request.id, actor: user)
assert {:error, _} = Membership.approve_join_request(request.id, actor: user)
end
end
describe "approve_join_request/2 defaults" do
test "created member has join_date and membership_fee_type when not in form_data" do
request = Fixtures.submitted_join_request_fixture()
user = Fixtures.user_with_role_fixture("normal_user")
assert {:ok, _} = Membership.approve_join_request(request.id, actor: user)
request_email = request.email
[member] =
Member
|> Ash.Query.filter(expr(^ref(:email) == ^request_email))
|> Ash.read!(actor: SystemActor.get_system_actor(), domain: Membership)
assert member.join_date != nil
assert member.membership_fee_type_id != nil
end
end
end

View file

@ -0,0 +1,115 @@
defmodule Mv.Membership.JoinRequestApprovalPolicyTest do
@moduledoc """
Policy tests for JoinRequest approval UI (Step 2).
Asserts that approve/reject and list are allowed for normal_user and admin,
and forbidden for read_only, own_data, and actor: nil.
No UI; domain and resource policies only.
"""
use Mv.DataCase, async: true
alias Mv.Fixtures
alias Mv.Membership
describe "list_join_requests/1" do
test "normal_user can list join requests" do
user = Fixtures.user_with_role_fixture("normal_user")
assert {:ok, _list} = Membership.list_join_requests(actor: user)
end
test "admin can list join requests" do
user = Fixtures.user_with_role_fixture("admin")
assert {:ok, _list} = Membership.list_join_requests(actor: user)
end
test "read_only cannot list join requests" do
user = Fixtures.user_with_role_fixture("read_only")
assert {:error, %Ash.Error.Forbidden{}} = Membership.list_join_requests(actor: user)
end
test "own_data cannot list join requests" do
user = Fixtures.user_with_role_fixture("own_data")
assert {:error, %Ash.Error.Forbidden{}} = Membership.list_join_requests(actor: user)
end
test "actor nil cannot list join requests" do
assert {:error, %Ash.Error.Forbidden{}} = Membership.list_join_requests(actor: nil)
end
end
describe "approve_join_request/2" do
setup do
request = Fixtures.submitted_join_request_fixture()
%{request: request}
end
test "normal_user can approve a submitted join request", %{request: request} do
user = Fixtures.user_with_role_fixture("normal_user")
assert {:ok, approved} = Membership.approve_join_request(request.id, actor: user)
assert approved.status == :approved
assert approved.approved_at != nil
assert approved.reviewed_by_user_id == user.id
end
test "admin can approve a submitted join request", %{request: request} do
user = Fixtures.user_with_role_fixture("admin")
assert {:ok, approved} = Membership.approve_join_request(request.id, actor: user)
assert approved.status == :approved
end
test "read_only cannot approve", %{request: request} do
user = Fixtures.user_with_role_fixture("read_only")
assert {:error, %Ash.Error.Forbidden{}} =
Membership.approve_join_request(request.id, actor: user)
end
test "own_data cannot approve", %{request: request} do
user = Fixtures.user_with_role_fixture("own_data")
assert {:error, %Ash.Error.Forbidden{}} =
Membership.approve_join_request(request.id, actor: user)
end
test "actor nil cannot approve", %{request: request} do
assert {:error, %Ash.Error.Forbidden{}} =
Membership.approve_join_request(request.id, actor: nil)
end
end
describe "reject_join_request/2" do
setup do
request = Fixtures.submitted_join_request_fixture()
%{request: request}
end
test "normal_user can reject a submitted join request", %{request: request} do
user = Fixtures.user_with_role_fixture("normal_user")
assert {:ok, rejected} = Membership.reject_join_request(request.id, actor: user)
assert rejected.status == :rejected
assert rejected.rejected_at != nil
assert rejected.reviewed_by_user_id == user.id
end
test "admin can reject a submitted join request", %{request: request} do
user = Fixtures.user_with_role_fixture("admin")
assert {:ok, rejected} = Membership.reject_join_request(request.id, actor: user)
assert rejected.status == :rejected
end
test "read_only cannot reject", %{request: request} do
user = Fixtures.user_with_role_fixture("read_only")
assert {:error, %Ash.Error.Forbidden{}} =
Membership.reject_join_request(request.id, actor: user)
end
test "own_data cannot reject", %{request: request} do
user = Fixtures.user_with_role_fixture("own_data")
assert {:error, %Ash.Error.Forbidden{}} =
Membership.reject_join_request(request.id, actor: user)
end
test "actor nil cannot reject", %{request: request} do
assert {:error, %Ash.Error.Forbidden{}} =
Membership.reject_join_request(request.id, actor: nil)
end
end
end

View file

@ -212,6 +212,72 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
end
end
describe "join_requests routes (approval UI, Step 2)" do
test "normal_user can access /join_requests" do
user = Fixtures.user_with_role_fixture("normal_user")
conn = conn_with_user("/join_requests", user) |> CheckPagePermission.call([])
refute conn.halted
end
test "normal_user can access /join_requests/:id" do
request = Fixtures.submitted_join_request_fixture()
user = Fixtures.user_with_role_fixture("normal_user")
conn = conn_with_user("/join_requests/#{request.id}", user) |> CheckPagePermission.call([])
refute conn.halted
end
test "read_only cannot access /join_requests" do
user = Fixtures.user_with_role_fixture("read_only")
conn = conn_with_user("/join_requests", user) |> CheckPagePermission.call([])
assert conn.halted
assert redirected_to(conn) == "/users/#{user.id}"
end
test "read_only cannot access /join_requests/:id" do
request = Fixtures.submitted_join_request_fixture()
user = Fixtures.user_with_role_fixture("read_only")
conn = conn_with_user("/join_requests/#{request.id}", user) |> CheckPagePermission.call([])
assert conn.halted
assert redirected_to(conn) == "/users/#{user.id}"
end
test "own_data cannot access /join_requests" do
user = Fixtures.user_with_role_fixture("own_data")
conn = conn_with_user("/join_requests", user) |> CheckPagePermission.call([])
assert conn.halted
assert redirected_to(conn) == "/users/#{user.id}"
end
test "own_data cannot access /join_requests/:id" do
request = Fixtures.submitted_join_request_fixture()
user = Fixtures.user_with_role_fixture("own_data")
conn = conn_with_user("/join_requests/#{request.id}", user) |> CheckPagePermission.call([])
assert conn.halted
assert redirected_to(conn) == "/users/#{user.id}"
end
test "admin can access /join_requests" do
user = Fixtures.user_with_role_fixture("admin")
conn = conn_with_user("/join_requests", user) |> CheckPagePermission.call([])
refute conn.halted
end
test "admin can access /join_requests/:id" do
request = Fixtures.submitted_join_request_fixture()
user = Fixtures.user_with_role_fixture("admin")
conn = conn_with_user("/join_requests/#{request.id}", user) |> CheckPagePermission.call([])
refute conn.halted
end
end
describe "error handling" do
test "user with no role is denied" do
user = Fixtures.user_with_role_fixture("admin")
@ -429,6 +495,22 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
conn = get(conn, "/admin/roles/#{id}/edit")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :member
test "GET /join_requests redirects to user profile", %{conn: conn, current_user: user} do
conn = get(conn, "/join_requests")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :member
test "GET /join_requests/:id redirects to user profile", %{
conn: conn,
current_user: user
} do
request = Fixtures.submitted_join_request_fixture()
conn = get(conn, "/join_requests/#{request.id}")
assert redirected_to(conn) == "/users/#{user.id}"
end
end
describe "integration: Mitglied (own_data) can access allowed paths via full router" do
@ -713,15 +795,37 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
conn = get(conn, "/admin/roles/#{id}")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :read_only
test "GET /join_requests redirects to user profile", %{conn: conn, current_user: user} do
conn = get(conn, "/join_requests")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :read_only
test "GET /join_requests/:id redirects to user profile", %{
conn: conn,
current_user: user
} do
request = Fixtures.submitted_join_request_fixture()
conn = get(conn, "/join_requests/#{request.id}")
assert redirected_to(conn) == "/users/#{user.id}"
end
end
# normal_user (Kassenwart): allowed /, /members, /members/new, /members/:id, /members/:id/edit, /groups, /groups/:slug
# normal_user (Kassenwart): allowed /, /members, /members/new, /members/:id, /members/:id/edit, /groups, /groups/:slug, /join_requests
describe "integration: normal_user (Kassenwart) allowed paths via full router" do
setup %{conn: conn, current_user: current_user} do
member = Mv.Fixtures.member_fixture()
group = Mv.Fixtures.group_fixture()
join_request = Fixtures.submitted_join_request_fixture()
{:ok, conn: conn, current_user: current_user, member_id: member.id, group_slug: group.slug}
{:ok,
conn: conn,
current_user: current_user,
member_id: member.id,
group_slug: group.slug,
join_request_id: join_request.id}
end
@tag role: :normal_user
@ -804,6 +908,18 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
conn = get(conn, "/users/#{user.id}/show/edit")
assert conn.status == 200
end
@tag role: :normal_user
test "GET /join_requests returns 200", %{conn: conn} do
conn = get(conn, "/join_requests")
assert conn.status == 200
end
@tag role: :normal_user
test "GET /join_requests/:id returns 200", %{conn: conn, join_request_id: id} do
conn = get(conn, "/join_requests/#{id}")
assert conn.status == 200
end
end
describe "integration: normal_user denied paths via full router" do

View file

@ -299,4 +299,38 @@ defmodule Mv.Fixtures do
{:error, error} -> raise "Failed to create group: #{inspect(error)}"
end
end
@doc """
Creates a join request in status :submitted (for approval UI tests).
Uses the public flow: submit_join_request then confirm_join_request with a known token.
Returns the JoinRequest struct so tests can use its id for approve/reject.
## Parameters
- `attrs` - Optional map: :email, :first_name, :last_name, :form_data, :schema_version.
Defaults: unique email; confirmation_token is generated and used internally.
## Returns
- JoinRequest struct with status :submitted
## Examples
iex> request = submitted_join_request_fixture()
iex> request.status
:submitted
iex> request = submitted_join_request_fixture(%{first_name: "Jane", last_name: "Doe"})
"""
def submitted_join_request_fixture(attrs \\ %{}) do
token = "fixture-token-#{System.unique_integer([:positive])}"
base = %{
email: "join#{System.unique_integer([:positive])}@example.com",
confirmation_token: token
}
attrs = base |> Map.merge(attrs) |> Map.put(:confirmation_token, token)
{:ok, _} = Membership.submit_join_request(attrs, actor: nil)
{:ok, request} = Membership.confirm_join_request(token, actor: nil)
request
end
end