test: add tweet creation + comment tests (13 tests)

Covers: create, blank content validation, guest restriction, read,
owner edit, non-owner edit forbidden, owner delete, non-owner delete
forbidden, reply creation, comment_count aggregate, tweet owner
deletes comment, third party forbidden, guest comment forbidden.

Key learnings:
- Tweet :destroy is not primary; use Ash.Changeset.for_destroy(:destroy)
- relate_actor fails with Invalid (not Forbidden) when no actor
- Ash.get returns NotFound error on miss; pass not_found_error?: false for nil

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-12 19:57:35 -04:00
parent d7345ba234
commit c3ccab5fc5
2 changed files with 223 additions and 1 deletions

View File

@@ -17,7 +17,9 @@
- [ ] No CHECK constraint on `likes >= 0` at DB level (low priority, app logic prevents it)
- [ ] `read :following_feed` — nil actor returns empty list (not a bug)
- [ ] No search for users or tweets
- [ ] Missing test coverage: comments, tweet creation, auth flows
- [x] Tweet creation, update, delete, comment tests (13 tests)
- [ ] Missing test coverage: auth flows
- [ ] No pagination on user list (`/users`)
## Notes

View File

@@ -0,0 +1,220 @@
defmodule Mixer.Posts.TweetTest do
use Mixer.DataCase, async: true
require Ash.Query
alias Mixer.Accounts.User
alias Mixer.Posts.Tweet
describe "tweet creation" do
test "a user can create a tweet" do
user = user_fixture("poster@example.com", "poster")
assert {:ok, tweet} =
Tweet
|> Ash.Changeset.for_create(:create, %{content: "hello world"}, actor: user)
|> Ash.create()
assert tweet.content == "hello world"
assert tweet.user_id == user.id
assert tweet.state == :posted
assert tweet.likes == 0
end
test "tweet content cannot be blank" do
user = user_fixture("blank@example.com", "blankuser")
assert {:error, error} =
Tweet
|> Ash.Changeset.for_create(:create, %{content: nil}, actor: user)
|> Ash.create()
assert Exception.message(error) =~ "content"
end
test "guests cannot create tweets" do
assert {:error, _error} =
Tweet
|> Ash.Changeset.for_create(:create, %{content: "spam"})
|> Ash.create()
end
test "all users can read tweets" do
user = user_fixture("readable@example.com", "readable")
Tweet
|> Ash.Changeset.for_create(:create, %{content: "public post"}, actor: user)
|> Ash.create!()
tweets = Tweet |> Ash.read!(authorize?: false)
assert length(tweets) >= 1
end
end
describe "tweet update" do
test "owner can edit their tweet" do
user = user_fixture("editor@example.com", "editor")
tweet = tweet_fixture(user, "original content")
assert {:ok, updated} =
tweet
|> Ash.Changeset.for_update(:update, %{content: "edited content"}, actor: user)
|> Ash.update()
assert updated.content == "edited content"
end
test "non-owner cannot edit a tweet" do
owner = user_fixture("owner@example.com", "tweetowner")
other = user_fixture("other@example.com", "otheruser")
tweet = tweet_fixture(owner, "owner's post")
assert {:error, error} =
tweet
|> Ash.Changeset.for_update(:update, %{content: "hacked"}, actor: other)
|> Ash.update()
assert Exception.message(error) =~ "forbidden"
end
end
describe "tweet deletion" do
test "owner can delete their tweet" do
user = user_fixture("deleter@example.com", "deleter")
tweet = tweet_fixture(user, "to be deleted")
assert :ok =
tweet
|> Ash.Changeset.for_destroy(:destroy, %{}, actor: user)
|> Ash.destroy()
assert {:ok, nil} = Tweet |> Ash.get(tweet.id, authorize?: false, not_found_error?: false)
end
test "non-owner cannot delete a tweet" do
owner = user_fixture("owner2@example.com", "owner2")
other = user_fixture("other2@example.com", "other2")
tweet = tweet_fixture(owner, "protected post")
assert {:error, error} =
tweet
|> Ash.Changeset.for_destroy(:destroy, %{}, actor: other)
|> Ash.destroy()
assert Exception.message(error) =~ "forbidden"
end
end
describe "comments (replies)" do
test "a user can reply to a tweet" do
author = user_fixture("author@example.com", "author")
replier = user_fixture("replier@example.com", "replier")
parent = tweet_fixture(author, "parent post")
assert {:ok, comment} =
Tweet
|> Ash.Changeset.for_create(
:create,
%{content: "great post!", parent_tweet_id: parent.id},
actor: replier
)
|> Ash.create()
assert comment.parent_tweet_id == parent.id
assert comment.user_id == replier.id
end
test "comment_count reflects number of replies" do
author = user_fixture("countauthor@example.com", "countauthor")
replier = user_fixture("countreplier@example.com", "countreplier")
parent = tweet_fixture(author, "tweet with replies")
Tweet
|> Ash.Changeset.for_create(:create, %{content: "reply 1", parent_tweet_id: parent.id}, actor: replier)
|> Ash.create!()
Tweet
|> Ash.Changeset.for_create(:create, %{content: "reply 2", parent_tweet_id: parent.id}, actor: replier)
|> Ash.create!()
loaded = Tweet |> Ash.get!(parent.id, load: [:comment_count], authorize?: false)
assert loaded.comment_count == 2
end
test "tweet owner can delete a comment on their tweet" do
author = user_fixture("tweetowner3@example.com", "tweetowner3")
replier = user_fixture("commenter@example.com", "commenter")
parent = tweet_fixture(author, "parent tweet")
comment =
Tweet
|> Ash.Changeset.for_create(
:create,
%{content: "a comment", parent_tweet_id: parent.id},
actor: replier
)
|> Ash.create!()
# Tweet owner (author) can delete someone else's comment on their post
assert :ok =
comment
|> Ash.Changeset.for_destroy(:destroy, %{}, actor: author)
|> Ash.destroy()
end
test "a third party cannot delete a comment they don't own" do
author = user_fixture("tweetowner4@example.com", "tweetowner4")
replier = user_fixture("commenter2@example.com", "commenter2")
bystander = user_fixture("bystander@example.com", "bystander")
parent = tweet_fixture(author, "parent tweet 2")
comment =
Tweet
|> Ash.Changeset.for_create(
:create,
%{content: "a comment", parent_tweet_id: parent.id},
actor: replier
)
|> Ash.create!()
assert {:error, error} =
comment
|> Ash.Changeset.for_destroy(:destroy, %{}, actor: bystander)
|> Ash.destroy()
assert Exception.message(error) =~ "forbidden"
end
test "guests cannot post comments" do
author = user_fixture("tweetowner5@example.com", "tweetowner5")
parent = tweet_fixture(author, "parent post 3")
assert {:error, _error} =
Tweet
|> Ash.Changeset.for_create(
:create,
%{content: "spam comment", parent_tweet_id: parent.id}
)
|> Ash.create()
end
end
# ── helpers ───────────────────────────────────────────────────────────────
defp user_fixture(email, username) do
User
|> Ash.Changeset.for_create(:register_with_password, %{
email: email,
password: "password1234",
password_confirmation: "password1234",
username: username
})
|> Ash.create!(authorize?: false)
end
defp tweet_fixture(user, content) do
Tweet
|> Ash.Changeset.for_create(:create, %{content: content}, actor: user)
|> Ash.create!()
end
end