test/openagents/forum_test.exs

main at 58e6347eeb72 · 9 KB

defmodule OpenAgents.ForumTest do
  # Sync, and it has to be. The announcement tests subscribe to `forum:posts`,
  # which is one global PubSub topic with no per-test scope, and then assert
  # that *no further* announcement arrives. Run concurrently with any other
  # async module that writes a topic or a post — `OpenAgents.Forum.TipsTest`
  # does — those refutations catch the other module's broadcast and fail. ExUnit
  # runs sync modules after the async ones finish, so this removes the race
  # rather than widening the assertions until they stop noticing it.
  use OpenAgents.DataCase, async: false

  alias OpenAgents.Forum

  defp user do
    {:ok, user} =
      %OpenAgents.Accounts.User{}
      |> Ecto.Changeset.change(%{
        github_id: System.unique_integer([:positive]),
        github_login: "forum-test-#{System.unique_integer()}",
        github_name: "Forum Test",
        github_avatar_url: "https://example.com/a.png"
      })
      |> Repo.insert()

    user
  end

  defp forum do
    {:ok, forum} =
      %OpenAgents.Forum.Forum{}
      |> OpenAgents.Forum.Forum.changeset(%{slug: "general", title: "General"})
      |> Repo.insert()

    forum
  end

  defp actor do
    %{
      actor_ref: "agent:user_ed8297d8-1279-4b43-a1e7-f7867da19e20",
      actor_display_name: "Orrery",
      actor_slug: "orrery"
    }
  end

  describe "create_topic/2" do
    test "creates a topic and its first post in one transaction" do
      forum = forum()

      {:ok, topic} =
        Forum.create_topic(
          forum,
          Map.merge(actor(), %{title: "Hello", slug: "hello", body_text: "First post"})
        )

      assert topic.title == "Hello"
      assert topic.post_count == 1
      [post] = Forum.list_posts(topic)
      assert post.body_text == "First post"
      assert post.post_number == 1
      assert topic.first_post_id == post.id
    end
  end

  describe "create_post/3" do
    test "appends posts with incrementing numbers and bumps the topic" do
      forum = forum()

      {:ok, topic} =
        Forum.create_topic(forum, Map.merge(actor(), %{title: "T", slug: "t", body_text: "a"}))

      {:ok, second} = Forum.create_post(topic, Map.merge(actor(), %{body_text: "b"}))
      assert second.post_number == 2

      bumped = Forum.get_topic!(topic.id)
      assert bumped.latest_post_id == second.id
    end

    test "refuses posts to closed topics" do
      forum = forum()

      {:ok, topic} =
        Forum.create_topic(forum, Map.merge(actor(), %{title: "T", slug: "t", body_text: "a"}))

      {:ok, _} = Forum.set_topic_state(topic, "closed")

      assert {:error, :topic_closed} =
               Forum.create_post(topic, Map.merge(actor(), %{body_text: "b"}))
    end
  end

  describe "list_recent_posts/1" do
    test "returns one row per topic, newest post first, with topic and board loaded" do
      board = forum()

      {:ok, quiet} =
        Forum.create_topic(
          board,
          Map.merge(actor(), %{title: "Quiet", slug: "quiet", body_text: "a"})
        )

      {:ok, busy} =
        Forum.create_topic(
          board,
          Map.merge(actor(), %{title: "Busy", slug: "busy", body_text: "b"})
        )

      {:ok, _} = Forum.create_post(busy, Map.merge(actor(), %{body_text: "b2"}))
      {:ok, newest} = Forum.create_post(busy, Map.merge(actor(), %{body_text: "b3"}))

      assert [first, second] = Forum.list_recent_posts()

      # The busy thread contributes its newest post and nothing else, so one
      # active topic cannot fill a caller's digest of the forum.
      assert first.id == newest.id
      assert first.topic.id == busy.id
      assert first.topic.forum.slug == "general"
      assert second.topic.id == quiet.id
    end

    test "leaves out a board that is kept off listings" do
      {:ok, hidden} =
        %OpenAgents.Forum.Forum{}
        |> OpenAgents.Forum.Forum.changeset(%{
          slug: "void",
          title: "Void",
          discoverability: "unlisted"
        })
        |> Repo.insert()

      {:ok, _topic} =
        Forum.create_topic(
          hidden,
          Map.merge(actor(), %{title: "Smoke test", slug: "smoke-test", body_text: "ping"})
        )

      assert Forum.list_recent_posts() == []

      # An operator reads the board by its slug, and still does not meet it in
      # a listing.
      assert Forum.list_recent_posts(operator?: true) == []
      assert {:ok, _} = Forum.fetch_readable_forum_by_slug("void")
    end

    test "leaves out a private board unless the caller is an operator" do
      {:ok, private} =
        %OpenAgents.Forum.Forum{}
        |> OpenAgents.Forum.Forum.changeset(%{
          slug: "operators",
          title: "Operators",
          visibility: "private"
        })
        |> Repo.insert()

      {:ok, _topic} =
        Forum.create_topic(
          private,
          Map.merge(actor(), %{title: "Internal", slug: "internal", body_text: "only us"})
        )

      assert Forum.list_recent_posts() == []
      assert [post] = Forum.list_recent_posts(operator?: true)
      assert post.topic.forum.slug == "operators"
    end

    test "leaves out a hidden post and caps the rows" do
      board = forum()

      {:ok, topic} =
        Forum.create_topic(board, Map.merge(actor(), %{title: "T", slug: "t", body_text: "one"}))

      {:ok, second} = Forum.create_post(topic, Map.merge(actor(), %{body_text: "two"}))
      {:ok, _} = Forum.hide_post(second)

      assert [post] = Forum.list_recent_posts(limit: 1)
      assert post.body_text == "one"
    end
  end

  describe "identity linking" do
    test "approve links an actor to an account and resolves it" do
      user = user()
      {:ok, link} = Forum.start_actor_link(user, "agent:user_1234")

      assert link.status == "pending"

      {:ok, linked} = Forum.approve_actor_link(link)
      assert linked.status == "linked"
      assert Forum.actor_user("agent:user_1234").id == user.id
      assert Forum.actor_user("agent:user_other") == nil
    end

    test "reject leaves nothing resolvable" do
      user = user()
      {:ok, link} = Forum.start_actor_link(user, "agent:user_5678")
      {:ok, rejected} = Forum.reject_actor_link(link)
      assert rejected.status == "rejected"
      assert Forum.actor_user("agent:user_5678") == nil
    end
  end

  describe "announcements" do
    test "a committed topic announces itself once, after the commit" do
      board = forum()
      :ok = Forum.subscribe_posts()

      {:ok, topic} =
        Forum.create_topic(
          board,
          Map.merge(actor(), %{title: "Announced", slug: "announced", body_text: "body"})
        )

      # One message, not two: the first post is written inside the topic's own
      # transaction, and a subscriber told from in there would re-read a forum
      # that does not have the topic yet.
      assert_receive {:forum_posts_changed, topic_id}
      assert topic_id == topic.id
      refute_receive {:forum_posts_changed, _other}, 20

      # The row the subscriber re-reads is already durable when it hears.
      assert [post] = Forum.list_recent_posts(limit: 1)
      assert post.topic_id == topic.id
    end

    test "a committed post announces itself and a refused one stays silent" do
      board = forum()

      {:ok, topic} =
        Forum.create_topic(
          board,
          Map.merge(actor(), %{title: "Closing soon", slug: "closing-soon", body_text: "body"})
        )

      :ok = Forum.subscribe_posts()

      {:ok, _post} = Forum.create_post(topic, Map.merge(actor(), %{body_text: "still open"}))
      assert_receive {:forum_posts_changed, _topic_id}

      # Closing announces as well. A page left rendering a composer on a topic
      # that has been closed is the same defect seen from the other side.
      {:ok, closed} = Forum.set_topic_state(topic, "closed")
      assert_receive {:forum_posts_changed, _closed_topic_id}

      assert {:error, :topic_closed} =
               Forum.create_post(closed, Map.merge(actor(), %{body_text: "too late"}))

      refute_receive {:forum_posts_changed, _topic_id}, 20
    end

    test "a moderated post announces itself" do
      board = forum()

      {:ok, topic} =
        Forum.create_topic(
          board,
          Map.merge(actor(), %{title: "Moderated", slug: "moderated", body_text: "body"})
        )

      {:ok, post} = Forum.create_post(topic, Map.merge(actor(), %{body_text: "said in haste"}))

      :ok = Forum.subscribe_posts()

      # A post that has been hidden or deleted is gone from every authorized
      # read, so a surface told nothing keeps rendering it.
      {:ok, hidden} = Forum.hide_post(post, nil)
      assert_receive {:forum_posts_changed, hidden_topic_id}
      assert hidden_topic_id == topic.id

      {:ok, _deleted} = Forum.delete_post(hidden, nil)
      assert_receive {:forum_posts_changed, _deleted_topic_id}

      # Pinning changes where a board sorts the topic, which is what a board
      # page renders.
      {:ok, _pinned} = Forum.pin_topic(topic, true)
      assert_receive {:forum_posts_changed, _pinned_topic_id}
    end

    test "a new topic moves the board counter the board list prints" do
      board = forum()

      assert board.topic_count == 0

      {:ok, _topic} =
        Forum.create_topic(
          board,
          Map.merge(actor(), %{title: "Counted", slug: "counted", body_text: "body"})
        )

      # The board list prints this counter. Counting the topics instead would
      # load every board's collection to measure it, once per board.
      assert [listed] = Forum.list_public_forums()
      assert listed.id == board.id
      assert listed.topic_count == 1
    end
  end
end