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