From 3672ef0d032217621f61f401583497a56b013d3f Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 9 Mar 2026 17:02:30 +0100 Subject: [PATCH] test: add tests for join mail confirmation --- docs/page-permission-route-coverage.md | 2 + .../controllers/join_confirm_controller.ex | 45 +++++++++ lib/mv_web/router.ex | 3 + .../join_request_submit_email_test.exs | 34 +++++++ .../join_confirm_controller_test.exs | 93 +++++++++++++++++++ 5 files changed, 177 insertions(+) create mode 100644 lib/mv_web/controllers/join_confirm_controller.ex create mode 100644 test/membership/join_request_submit_email_test.exs create mode 100644 test/mv_web/controllers/join_confirm_controller_test.exs diff --git a/docs/page-permission-route-coverage.md b/docs/page-permission-route-coverage.md index b8eafbd..f91ee0c 100644 --- a/docs/page-permission-route-coverage.md +++ b/docs/page-permission-route-coverage.md @@ -38,6 +38,8 @@ This document lists all protected routes, which permission set may access them, - `/auth*`, `/register`, `/reset`, `/sign-in`, `/sign-out`, `/confirm*`, `/password-reset*`, `/set_locale` +The join confirmation route `GET /confirm_join/:token` is public (matched by `/confirm*`). Unit tests: `test/mv_web/controllers/join_confirm_controller_test.exs` (stubbed callback, no integration). + ## Test Coverage **File:** `test/mv_web/plugs/check_page_permission_test.exs` diff --git a/lib/mv_web/controllers/join_confirm_controller.ex b/lib/mv_web/controllers/join_confirm_controller.ex new file mode 100644 index 0000000..a1247f3 --- /dev/null +++ b/lib/mv_web/controllers/join_confirm_controller.ex @@ -0,0 +1,45 @@ +defmodule MvWeb.JoinConfirmController do + @moduledoc """ + Handles GET /confirm_join/:token for the public join flow (double opt-in). + + Calls a configurable callback (default Mv.Membership) so tests can stub the + dependency. Public route; no authentication required. + """ + use MvWeb, :controller + + def confirm(conn, %{"token" => token}) when is_binary(token) do + callback = Application.get_env(:mv, :join_confirm_callback, Mv.Membership) + + case callback.confirm_join_request(token, actor: nil) do + {:ok, _request} -> + success_response(conn) + + {:error, :token_expired} -> + expired_response(conn) + + {:error, _} -> + invalid_response(conn) + end + end + + def confirm(conn, _params), do: invalid_response(conn) + + defp success_response(conn) do + conn + |> put_resp_content_type("text/html") + |> send_resp(200, gettext("Thank you, we have received your request.")) + end + + defp expired_response(conn) do + conn + |> put_resp_content_type("text/html") + |> send_resp(200, gettext("This link has expired. Please submit the form again.")) + end + + defp invalid_response(conn) do + conn + |> put_resp_content_type("text/html") + |> put_status(404) + |> send_resp(404, gettext("Invalid or expired link.")) + end +end diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex index 8a4e6c0..3ab264f 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -126,6 +126,9 @@ defmodule MvWeb.Router do overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.DaisyUI], gettext_backend: {MvWeb.Gettext, "auth"} + # Public join confirmation (double opt-in); /confirm* is already public in CheckPagePermission + get "/confirm_join/:token", JoinConfirmController, :confirm + # Remove this if you do not use the magic link strategy. # magic_sign_in_route(Mv.Accounts.User, :magic_link, # auth_routes_prefix: "/auth", diff --git a/test/membership/join_request_submit_email_test.exs b/test/membership/join_request_submit_email_test.exs new file mode 100644 index 0000000..87c989a --- /dev/null +++ b/test/membership/join_request_submit_email_test.exs @@ -0,0 +1,34 @@ +defmodule Mv.Membership.JoinRequestSubmitEmailTest do + @moduledoc """ + Unit tests for join request confirmation email on submit (Subtask 2). + + Asserts that submit_join_request triggers sending exactly one confirmation email + (to the request email, with confirm link). Uses Swoosh.Adapters.Test; no integration. + Sender is wired in implementation; test fails until then (TDD). + """ + use Mv.DataCase, async: true + + import Swoosh.TestAssertions + + alias Mv.Membership + + @valid_submit_attrs %{ + email: "join#{System.unique_integer([:positive])}@example.com" + } + + describe "submit_join_request/2 sends confirmation email" do + test "sends exactly one email to the request email with confirm link" do + token = "email-test-token-#{System.unique_integer([:positive])}" + attrs = Map.put(@valid_submit_attrs, :confirmation_token, token) + email = attrs.email + + assert {:ok, _request} = Membership.submit_join_request(attrs, actor: nil) + + assert_email_sent(fn email_sent -> + to_addresses = Enum.map(email_sent.to, &elem(&1, 1)) + to_string(email) in to_addresses and + (email_sent.html_body =~ "/confirm_join/" or email_sent.text_body =~ "/confirm_join/") + end) + end + end +end diff --git a/test/mv_web/controllers/join_confirm_controller_test.exs b/test/mv_web/controllers/join_confirm_controller_test.exs new file mode 100644 index 0000000..a8e4334 --- /dev/null +++ b/test/mv_web/controllers/join_confirm_controller_test.exs @@ -0,0 +1,93 @@ +defmodule MvWeb.JoinConfirmControllerTest do + @moduledoc """ + Unit tests for JoinConfirmController (Subtask 2). + + Stubs the join-confirm callback via Application config so no DB or domain is used. + Uses unauthenticated conn; route is public (/confirm*). + """ + use MvWeb.ConnCase, async: false + + # Stub modules for configurable callback (unit test: no real Membership calls) + defmodule JoinConfirmValidStub do + def confirm_join_request(_token, _opts), do: {:ok, %{}} + end + + defmodule JoinConfirmExpiredStub do + def confirm_join_request(_token, _opts), do: {:error, :token_expired} + end + + defmodule JoinConfirmInvalidStub do + def confirm_join_request(_token, _opts) do + {:error, Ash.Error.Query.NotFound.exception(resource: Mv.Membership.JoinRequest)} + end + end + + setup %{conn: conn} do + # Restore callback after each test so env does not leak + on_exit(fn -> + Application.delete_env(:mv, :join_confirm_callback) + end) + + # Build unauthenticated conn for public confirm route + unauth_conn = + build_conn() + |> init_test_session(%{}) + |> fetch_flash() + |> Plug.Conn.put_private(:ecto_sandbox, conn.private[:ecto_sandbox]) + + {:ok, conn: unauth_conn} + end + + describe "GET /confirm_join/:token" do + @tag role: :unauthenticated + test "valid token returns 200 and success message", %{conn: conn} do + Application.put_env(:mv, :join_confirm_callback, JoinConfirmValidStub) + + conn = get(conn, "/confirm_join/any-valid-token") + + assert response(conn, 200) =~ "received your request" + end + + @tag role: :unauthenticated + test "second request with same token still returns 200 (idempotent)", %{conn: conn} do + Application.put_env(:mv, :join_confirm_callback, JoinConfirmValidStub) + + first = get(conn, "/confirm_join/same-token") + second = get(conn, "/confirm_join/same-token") + + assert response(first, 200) =~ "received your request" + assert response(second, 200) =~ "received your request" + end + + @tag role: :unauthenticated + test "expired token returns 200 with expired message", %{conn: conn} do + Application.put_env(:mv, :join_confirm_callback, JoinConfirmExpiredStub) + + conn = get(conn, "/confirm_join/expired-token") + + assert response(conn, 200) =~ "expired" + assert response(conn, 200) =~ "submit" + end + + @tag role: :unauthenticated + test "unknown or invalid token returns 404 with error message", %{conn: conn} do + Application.put_env(:mv, :join_confirm_callback, JoinConfirmInvalidStub) + + conn = get(conn, "/confirm_join/nonexistent-token") + + assert response(conn, 404) =~ "Invalid" + end + + @tag role: :unauthenticated + test "route is public (unauthenticated request returns 200, not redirect to sign-in)", %{ + conn: conn + } do + Application.put_env(:mv, :join_confirm_callback, JoinConfirmValidStub) + + conn = get(conn, "/confirm_join/public-test-token") + + assert conn.status == 200 + refute redirected_to(conn) =~ "/sign-in" + end + end +end