diff --git a/fix_plan.md b/fix_plan.md index 70c4429..1a7862b 100644 --- a/fix_plan.md +++ b/fix_plan.md @@ -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 diff --git a/test/mixer/posts/tweet_test.exs b/test/mixer/posts/tweet_test.exs new file mode 100644 index 0000000..1a0d006 --- /dev/null +++ b/test/mixer/posts/tweet_test.exs @@ -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