test/openagents_web/live/home_forum_panel_live_test.exs

main at 58e6347eeb72 · 6 KB

defmodule OpenAgentsWeb.HomeForumPanelLiveTest do
  @moduledoc """
  The `Recent posts` panel on the signed-in homepage (#137).

  The panel inherits the dashboard contract `#128` set: it reads through the
  forum's own viewer-authorized read, caps its rows, links to `/forum` rather
  than to one board, and says what is empty instead of rendering nothing.

  What matters here is that it cannot say more than `/forum` says. A board the
  viewer cannot read stays out. A board kept off listings stays out, whoever is
  looking, because a board opened as a smoke test must not arrive on the
  homepage. And a migrated post reads under the identity its thread shows it
  under, so the dashboard cannot attribute a post to an account the forum
  would not.
  """

  use OpenAgentsWeb.ConnCase, async: false

  import Phoenix.LiveViewTest

  alias OpenAgents.Forum
  alias OpenAgents.Repo

  @legacy_actor %{
    actor_ref: "agent:user_ed8297d8-1279-4b43-a1e7-f7867da19e20",
    actor_display_name: "Orrery",
    actor_slug: "orrery"
  }

  setup %{conn: conn} do
    %{conn: sign_in(conn, "home-forum-reader")}
  end

  test "the panel lists the newest post of each recently active topic", %{conn: conn} do
    board = board!("product-promises", "Product promises")
    quiet = topic!(board, "What a promise costs", "what-a-promise-costs")
    busy = topic!(board, "Where the receipts live", "where-the-receipts-live")
    {:ok, newest} = Forum.create_post(busy, Map.merge(@legacy_actor, %{body_text: "Still here"}))

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

    assert has_element?(view, "#dashboard-post-list")
    assert has_element?(view, "#dashboard-post-#{newest.id}")
    assert has_element?(view, ~s{a[href="/forum/t/#{busy.id}"]}, "Where the receipts live")
    assert has_element?(view, ~s{a[href="/forum/t/#{quiet.id}"]}, "What a promise costs")

    # The board is named beside the author, because a cross-board digest is
    # unreadable without the board.
    assert has_element?(view, "#dashboard-post-#{newest.id} .post-rail__meta", "Product promises")

    # One row per topic: the busy thread's earlier posts are not rows of their
    # own, so a single thread cannot fill the panel.
    assert rows(view) == 2
  end

  test "the panel points at the forum, not at one board", %{conn: conn} do
    board = board!("product-promises", "Product promises")
    topic!(board, "What a promise costs", "what-a-promise-costs")

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

    assert has_element?(view, ~s{a[href="/forum"]}, "View all")
    refute has_element?(view, ~s{a[href="/forum/f/product-promises"]})
  end

  test "the panel caps its rows", %{conn: conn} do
    board = board!("product-promises", "Product promises")

    for index <- 1..9 do
      topic!(board, "Topic #{index}", "topic-#{index}")
    end

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

    assert rows(view) == 6
  end

  test "a post on a board the viewer cannot read never appears", %{conn: conn} do
    private = board!("operators", "Operators", visibility: "private")
    topic = topic!(private, "Rotating the signing key", "rotating-the-signing-key")

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

    refute html =~ "Rotating the signing key"
    refute has_element?(view, ~s{a[href="/forum/t/#{topic.id}"]})

    # The same post reaches the operator whose board it is, which is what makes
    # the refusal above an authorization result rather than an empty database.
    {:ok, operator, _html} = live(log_in_admin_user(build_conn(), "home-forum-operator"), ~p"/")
    assert has_element?(operator, ~s{a[href="/forum/t/#{topic.id}"]})
  end

  test "a board kept off listings never appears, operator or not", %{conn: conn} do
    void = board!("void", "Void", discoverability: "unlisted")
    topic = topic!(void, "Smoke test", "smoke-test")

    {:ok, view, _html} = live(conn, ~p"/")
    refute has_element?(view, ~s{a[href="/forum/t/#{topic.id}"]})

    {:ok, operator, _html} = live(log_in_admin_user(build_conn(), "home-forum-operator"), ~p"/")
    refute has_element?(operator, ~s{a[href="/forum/t/#{topic.id}"]})

    # The board still answers to its slug. Keeping it off the homepage is a
    # listing rule, not a permission.
    assert {:ok, _forum} = Forum.fetch_readable_forum_by_slug("void")
  end

  test "a migrated post reads under the identity its thread shows", %{conn: conn} do
    board = board!("product-promises", "Product promises")
    topic = topic!(board, "What a promise costs", "what-a-promise-costs")
    [post] = Forum.list_posts(topic)

    # An account that has not claimed the legacy identity, and one that has.
    # Neither may take the post's byline unless the thread gives it to them.
    stranger = github_user("home-forum-stranger", "orrery-lookalike")
    {:ok, link} = Forum.start_actor_link(stranger, @legacy_actor.actor_ref)
    {:ok, _linked} = Forum.approve_actor_link(link)

    {:ok, home, html} = live(conn, ~p"/")
    {:ok, thread, _html} = live(conn, ~p"/forum/t/#{topic.id}")

    byline = text_at(thread, "#posts-#{post.id} span.font-semibold")
    meta = text_at(home, "#dashboard-post-#{post.id} .post-rail__meta")

    assert byline == "Orrery"
    assert String.starts_with?(meta, byline)
    refute html =~ "orrery-lookalike"
  end

  test "with nothing readable the panel names its scope", %{conn: conn} do
    {:ok, view, _html} = live(conn, ~p"/")

    refute has_element?(view, "#dashboard-post-list")
    assert has_element?(view, ".panel__empty", "No posts yet on the boards you can read.")
  end

  defp sign_in(conn, key) do
    Plug.Test.init_test_session(conn, %{"user_id" => github_user(key).id})
  end

  defp board!(slug, title, opts \\ []) do
    {:ok, board} =
      %Forum.Forum{}
      |> Forum.Forum.changeset(%{
        slug: slug,
        title: title,
        visibility: Keyword.get(opts, :visibility, "public"),
        discoverability: Keyword.get(opts, :discoverability, "listed")
      })
      |> Repo.insert()

    board
  end

  defp topic!(board, title, slug) do
    {:ok, topic} =
      Forum.create_topic(
        board,
        Map.merge(@legacy_actor, %{
          title: title,
          slug: slug,
          body_text: "Body of #{title}",
          idempotency_key: Ecto.UUID.generate()
        })
      )

    topic
  end

  defp rows(view) do
    view
    |> render()
    |> LazyHTML.from_fragment()
    |> LazyHTML.query(".post-rail__row")
    |> Enum.count()
  end

  defp text_at(view, selector) do
    view
    |> render()
    |> LazyHTML.from_fragment()
    |> LazyHTML.query(selector)
    |> LazyHTML.text()
    |> String.trim()
  end
end