test/openagents_web/live/notifications_live_test.exs

58e6347eeb72 · 10 KB

defmodule OpenAgentsWeb.NotificationsLiveTest do
  use OpenAgentsWeb.ConnCase, async: false

  import Phoenix.LiveViewTest

  alias OpenAgents.Accounts.User
  alias OpenAgents.Issues
  alias OpenAgents.Notifications
  alias OpenAgents.Notifications.EmailChannel
  alias OpenAgents.Repo
  alias OpenAgents.Repositories

  defp setup_thread(conn, visibility) do
    reader = github_user("notifications-reader", "notifications-reader")
    actor = github_user("notifications-actor", "notifications-actor")

    {:ok, repository} =
      Repositories.create_repository(%{
        owner: "NotifyOrg",
        name: "notify-#{System.unique_integer([:positive])}",
        visibility: visibility,
        default_branch: "main"
      })

    {:ok, _reader_membership} = Repositories.add_member(repository, reader, "owner")
    {:ok, _actor_membership} = Repositories.add_member(repository, actor, "contributor")

    {:ok, issue} = Issues.create_issue(repository, %{title: "A tracked issue"}, reader)
    {:ok, _comment} = Issues.create_comment(issue, %{"body" => "an update"}, actor)

    conn = Plug.Test.init_test_session(conn, %{"user_id" => reader.id})

    %{conn: conn, reader: reader, actor: actor, repository: repository, issue: issue}
  end

  describe "the inbox" do
    test "shows a notification about an issue the reader can read", %{conn: conn} do
      %{conn: conn} = setup_thread(conn, "public")

      {:ok, view, _html} = live(conn, ~p"/notifications")

      assert has_element?(view, "#notifications-list")
      assert has_element?(view, "#notifications-unread-count")
      refute has_element?(view, "#notifications-empty")
    end

    test "shows an empty state for an account with nothing waiting", %{conn: conn} do
      conn = log_in_github_user(conn, "quiet-account")

      {:ok, view, _html} = live(conn, ~p"/notifications")

      assert has_element?(view, "#notifications-empty")
      refute has_element?(view, "#notifications-unread-count")
    end

    test "marking one read clears the unread badge", %{conn: conn} do
      %{conn: conn, reader: reader} = setup_thread(conn, "public")

      {:ok, view, _html} = live(conn, ~p"/notifications")

      [notification] = Notifications.list_notifications(reader)

      view |> element("#mark-read-#{notification.id}") |> render_click()

      refute has_element?(view, "#notifications-unread-count")
      assert Notifications.unread_count(reader) == 0
    end

    test "mark all read clears every unread record", %{conn: conn} do
      %{conn: conn, reader: reader, issue: issue, actor: actor} = setup_thread(conn, "public")
      {:ok, _second} = Issues.create_comment(issue, %{"body" => "and another"}, actor)

      {:ok, view, _html} = live(conn, ~p"/notifications")

      assert Notifications.unread_count(reader) == 2

      view |> element("#notifications-mark-all-read") |> render_click()

      refute has_element?(view, "#notifications-mark-all-read")
      assert Notifications.unread_count(reader) == 0
    end

    test "an anonymous visitor is sent to sign in", %{conn: conn} do
      assert {:error, {:redirect, %{to: path}}} = live(conn, ~p"/notifications")
      refute path == ~p"/notifications"
    end
  end

  describe "authorization" do
    test "a private repository notification disappears when membership ends", %{conn: conn} do
      %{conn: conn, reader: reader, actor: actor, repository: repository} =
        setup_thread(conn, "private")

      {:ok, view, _html} = live(conn, ~p"/notifications")
      assert has_element?(view, "#notifications-list")
      refute has_element?(view, "#notifications-empty")

      # `actor` is the remaining owner, so removing the reader leaves an owner
      # behind and the guard against removing the last one does not fire.
      {:ok, _promoted} = Repositories.add_member(repository, actor, "owner")
      :ok = Repositories.remove_member(repository, actor, reader.id)

      {:ok, view, _html} = live(conn, ~p"/notifications")

      assert has_element?(view, "#notifications-empty")
      refute has_element?(view, "#notifications-unread-count")
    end

    test "the inbox never renders another account's notifications", %{conn: conn} do
      %{issue: issue, actor: actor} = setup_thread(conn, "public")

      stranger = github_user("notifications-stranger", "notifications-stranger")
      {:ok, _comment} = Issues.create_comment(issue, %{"body" => "more"}, actor)

      conn = Plug.Test.init_test_session(build_conn(), %{"user_id" => stranger.id})
      {:ok, view, _html} = live(conn, ~p"/notifications")

      assert has_element?(view, "#notifications-empty")
    end
  end

  describe "preference controls" do
    test "turning a category off is recorded", %{conn: conn} do
      %{conn: conn, reader: reader} = setup_thread(conn, "public")

      {:ok, view, _html} = live(conn, ~p"/notifications")

      view
      |> form("#notification-preferences-form", %{
        "preferences" => %{
          "mentions_enabled" => "true",
          "issue_comments_enabled" => "false"
        }
      })
      |> render_change()

      preferences = Notifications.preferences(reader)

      assert preferences.mentions_enabled
      refute preferences.issue_comments_enabled
    end

    test "turning a category back on is recorded", %{conn: conn} do
      %{conn: conn, reader: reader} = setup_thread(conn, "public")
      {:ok, _off} = Notifications.update_preferences(reader, %{mentions_enabled: false})

      {:ok, view, _html} = live(conn, ~p"/notifications")

      view
      |> form("#notification-preferences-form", %{
        "preferences" => %{
          "mentions_enabled" => "true",
          "issue_comments_enabled" => "true"
        }
      })
      |> render_change()

      assert Notifications.preferences(reader).mentions_enabled
    end
  end

  describe "the email channel" do
    test "an address is taken, and is not a recipient until the code comes back", %{conn: conn} do
      %{conn: conn, reader: reader} = setup_thread(conn, "public")

      {:ok, view, _html} = live(conn, ~p"/notifications")

      assert has_element?(view, "#notification-email-address-form")
      refute has_element?(view, "#notification-email-code-form")
      refute has_element?(view, "#notification-email-channel-form")

      view
      |> form("#notification-email-address-form", %{"email" => %{"address" => "me@example.com"}})
      |> render_submit()

      pending = Repo.get!(User, reader.id)
      assert pending.notification_email == "me@example.com"
      assert EmailChannel.verified_address(pending) == nil

      # The switch that would turn the channel on is not offered yet, because
      # there is nowhere for it to send.
      assert has_element?(view, "#notification-email-code-form")
      refute has_element?(view, "#notification-email-channel-form")

      assert_receive {:email, %Swoosh.Email{subject: subject}}
      code = hd(Regex.run(~r/[0-9A-Z]{8}/, subject))

      view
      |> form("#notification-email-code-form", %{"email" => %{"code" => code}})
      |> render_submit()

      assert EmailChannel.verified_address(Repo.get!(User, reader.id)) == "me@example.com"
      assert has_element?(view, "#notification-email-channel-form")
      assert has_element?(view, "#notification-email-verified")
    end

    test "a wrong code leaves the address unverified", %{conn: conn} do
      %{conn: conn, reader: reader} = setup_thread(conn, "public")

      {:ok, view, _html} = live(conn, ~p"/notifications")

      view
      |> form("#notification-email-address-form", %{"email" => %{"address" => "me@example.com"}})
      |> render_submit()

      html =
        view
        |> form("#notification-email-code-form", %{"email" => %{"code" => "00000000"}})
        |> render_submit()

      assert html =~ "not right"
      assert EmailChannel.verified_address(Repo.get!(User, reader.id)) == nil
    end

    test "the channel switch is off until the account turns it on", %{conn: conn} do
      %{conn: conn, reader: reader} = setup_thread(conn, "public")

      {:ok, pending} = EmailChannel.set_address(reader, "me@example.com")
      assert_receive {:email, %Swoosh.Email{subject: subject}}
      {:ok, _verified} = EmailChannel.verify(pending, hd(Regex.run(~r/[0-9A-Z]{8}/, subject)))

      refute Notifications.preferences(reader).email_enabled

      {:ok, view, _html} = live(conn, ~p"/notifications")

      view
      |> form("#notification-email-channel-form", %{"channel" => %{"email_enabled" => "true"}})
      |> render_change()

      assert Notifications.preferences(reader).email_enabled
    end

    test "removing the address takes the switch with it", %{conn: conn} do
      %{conn: conn, reader: reader} = setup_thread(conn, "public")

      {:ok, pending} = EmailChannel.set_address(reader, "me@example.com")
      assert_receive {:email, %Swoosh.Email{subject: subject}}
      {:ok, _verified} = EmailChannel.verify(pending, hd(Regex.run(~r/[0-9A-Z]{8}/, subject)))

      {:ok, view, _html} = live(conn, ~p"/notifications")
      assert has_element?(view, "#notification-email-channel-form")

      view |> element("#notification-email-remove") |> render_click()

      refute has_element?(view, "#notification-email-channel-form")
      assert EmailChannel.verified_address(Repo.get!(User, reader.id)) == nil
    end

    test "a deployment with no mail provider collects no address at all", %{conn: conn} do
      configured = Application.get_env(:openagents, EmailChannel)
      Application.put_env(:openagents, EmailChannel, Keyword.put(configured, :deliverable, false))
      on_exit(fn -> Application.put_env(:openagents, EmailChannel, configured) end)

      %{conn: conn} = setup_thread(conn, "public")

      {:ok, view, _html} = live(conn, ~p"/notifications")

      assert has_element?(view, "#notifications-email-unavailable")
      refute has_element?(view, "#notification-email-address-form")
    end
  end

  describe "the subscribe control on an issue" do
    test "an author can unsubscribe and subscribe again", %{conn: conn} do
      %{conn: conn, reader: reader, repository: repository, issue: issue} =
        setup_thread(conn, "public")

      assert Notifications.subscribed?(issue, reader)

      {:ok, view, _html} =
        live(conn, ~p"/#{repository.owner}/#{repository.name}/issues/#{issue.number}")

      view |> element("#issue-subscription-toggle") |> render_click()
      refute Notifications.subscribed?(issue, reader)

      view |> element("#issue-subscription-toggle") |> render_click()
      assert Notifications.subscribed?(issue, reader)
    end

    test "an anonymous visitor sees no subscribe control", %{conn: conn} do
      %{repository: repository, issue: issue} = setup_thread(conn, "public")

      {:ok, view, _html} =
        live(build_conn(), ~p"/#{repository.owner}/#{repository.name}/issues/#{issue.number}")

      refute has_element?(view, "#issue-subscription-toggle")
    end
  end
end