fix: self-follow validation + add follow/unfollow tests
Self-follow check used get_attribute(:follower_id) which is nil at validation time because relate_actor runs after validations in Ash. Fixed to use context.actor.id directly. Added 9 tests covering: follow, follow idempotency, self-follow prevention, guest restriction, unfollow, unfollow noop, guest unfollow, follower/following counts, and am_i_following. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,11 +11,13 @@
|
|||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
|
- [x] Self-follow validation used `get_attribute(:follower_id)` which is nil at validation time (relate_actor runs after) — fixed to use `context.actor.id`
|
||||||
|
- [x] Follow/unfollow test coverage (9 tests)
|
||||||
- [ ] No pagination on user list (`/users`)
|
- [ ] No pagination on user list (`/users`)
|
||||||
- [ ] No CHECK constraint on `likes >= 0` at DB level (low priority, app logic prevents it)
|
- [ ] No CHECK constraint on `likes >= 0` at DB level (low priority, app logic prevents it)
|
||||||
- [ ] `read :following_feed` returns error if actor is nil — should be policy-guarded
|
- [ ] `read :following_feed` — nil actor returns empty list (not a bug)
|
||||||
- [ ] No search for users or tweets
|
- [ ] No search for users or tweets
|
||||||
- [ ] Missing test coverage: follow/unfollow, comments, tweet creation, auth flows
|
- [ ] Missing test coverage: comments, tweet creation, auth flows
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ defmodule Mixer.Accounts.Follow do
|
|||||||
accept [:following_id]
|
accept [:following_id]
|
||||||
change relate_actor(:follower)
|
change relate_actor(:follower)
|
||||||
|
|
||||||
validate fn changeset, _context ->
|
validate fn changeset, context ->
|
||||||
follower_id = Ash.Changeset.get_attribute(changeset, :follower_id)
|
actor_id = context.actor && context.actor.id
|
||||||
following_id = Ash.Changeset.get_attribute(changeset, :following_id)
|
following_id = Ash.Changeset.get_attribute(changeset, :following_id)
|
||||||
|
|
||||||
if follower_id == following_id do
|
if actor_id && actor_id == following_id do
|
||||||
{:error, field: :following_id, message: "You cannot follow yourself"}
|
{:error, field: :following_id, message: "You cannot follow yourself"}
|
||||||
else
|
else
|
||||||
:ok
|
:ok
|
||||||
|
|||||||
172
test/mixer/accounts/follow_test.exs
Normal file
172
test/mixer/accounts/follow_test.exs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
defmodule Mixer.Accounts.FollowTest do
|
||||||
|
use Mixer.DataCase, async: true
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
|
alias Mixer.Accounts.Follow
|
||||||
|
alias Mixer.Accounts.User
|
||||||
|
|
||||||
|
describe "follow" do
|
||||||
|
test "a user can follow another user" do
|
||||||
|
alice = user_fixture("alice@example.com", "alice")
|
||||||
|
bob = user_fixture("bob@example.com", "bob")
|
||||||
|
|
||||||
|
assert {:ok, follow} =
|
||||||
|
Follow
|
||||||
|
|> Ash.Changeset.for_create(:follow, %{following_id: bob.id}, actor: alice)
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
assert follow.follower_id == alice.id
|
||||||
|
assert follow.following_id == bob.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "following the same user twice is a noop (upsert)" do
|
||||||
|
alice = user_fixture("alice2@example.com", "alice2")
|
||||||
|
bob = user_fixture("bob2@example.com", "bob2")
|
||||||
|
|
||||||
|
assert {:ok, _} =
|
||||||
|
Follow
|
||||||
|
|> Ash.Changeset.for_create(:follow, %{following_id: bob.id}, actor: alice)
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
assert {:ok, _} =
|
||||||
|
Follow
|
||||||
|
|> Ash.Changeset.for_create(:follow, %{following_id: bob.id}, actor: alice)
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
assert count_follows(alice.id, bob.id) == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a user cannot follow themselves" do
|
||||||
|
alice = user_fixture("alice3@example.com", "alice3")
|
||||||
|
|
||||||
|
assert {:error, error} =
|
||||||
|
Follow
|
||||||
|
|> Ash.Changeset.for_create(:follow, %{following_id: alice.id}, actor: alice)
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
assert Exception.message(error) =~ "cannot follow yourself"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "guests cannot follow" do
|
||||||
|
bob = user_fixture("bob3@example.com", "bob3")
|
||||||
|
|
||||||
|
assert {:error, _error} =
|
||||||
|
Follow
|
||||||
|
|> Ash.Changeset.for_create(:follow, %{following_id: bob.id})
|
||||||
|
|> Ash.create()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "unfollow" do
|
||||||
|
test "a user can unfollow someone they follow" do
|
||||||
|
alice = user_fixture("alice4@example.com", "alice4")
|
||||||
|
bob = user_fixture("bob4@example.com", "bob4")
|
||||||
|
|
||||||
|
Follow
|
||||||
|
|> Ash.Changeset.for_create(:follow, %{following_id: bob.id}, actor: alice)
|
||||||
|
|> Ash.create!()
|
||||||
|
|
||||||
|
assert count_follows(alice.id, bob.id) == 1
|
||||||
|
|
||||||
|
assert :ok =
|
||||||
|
Follow
|
||||||
|
|> Ash.ActionInput.for_action(:unfollow, %{following_id: bob.id}, actor: alice)
|
||||||
|
|> Ash.run_action()
|
||||||
|
|
||||||
|
assert count_follows(alice.id, bob.id) == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unfollowing when not following is a noop" do
|
||||||
|
alice = user_fixture("alice5@example.com", "alice5")
|
||||||
|
bob = user_fixture("bob5@example.com", "bob5")
|
||||||
|
|
||||||
|
assert :ok =
|
||||||
|
Follow
|
||||||
|
|> Ash.ActionInput.for_action(:unfollow, %{following_id: bob.id}, actor: alice)
|
||||||
|
|> Ash.run_action()
|
||||||
|
|
||||||
|
assert count_follows(alice.id, bob.id) == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "guests cannot unfollow" do
|
||||||
|
bob = user_fixture("bob6@example.com", "bob6")
|
||||||
|
|
||||||
|
assert {:error, error} =
|
||||||
|
Follow
|
||||||
|
|> Ash.ActionInput.for_action(:unfollow, %{following_id: bob.id})
|
||||||
|
|> Ash.run_action()
|
||||||
|
|
||||||
|
assert Exception.message(error) =~ "forbidden"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "follower/following counts" do
|
||||||
|
test "follower_count and following_count reflect current follows" do
|
||||||
|
alice = user_fixture("alice6@example.com", "alice6")
|
||||||
|
bob = user_fixture("bob7@example.com", "bob7")
|
||||||
|
carol = user_fixture("carol@example.com", "carol")
|
||||||
|
|
||||||
|
Follow
|
||||||
|
|> Ash.Changeset.for_create(:follow, %{following_id: bob.id}, actor: alice)
|
||||||
|
|> Ash.create!()
|
||||||
|
|
||||||
|
Follow
|
||||||
|
|> Ash.Changeset.for_create(:follow, %{following_id: carol.id}, actor: alice)
|
||||||
|
|> Ash.create!()
|
||||||
|
|
||||||
|
Follow
|
||||||
|
|> Ash.Changeset.for_create(:follow, %{following_id: bob.id}, actor: carol)
|
||||||
|
|> Ash.create!()
|
||||||
|
|
||||||
|
alice_loaded = User |> Ash.get!(alice.id, load: [:follower_count, :following_count], authorize?: false)
|
||||||
|
bob_loaded = User |> Ash.get!(bob.id, load: [:follower_count, :following_count], authorize?: false)
|
||||||
|
|
||||||
|
assert alice_loaded.following_count == 2
|
||||||
|
assert alice_loaded.follower_count == 0
|
||||||
|
assert bob_loaded.follower_count == 2
|
||||||
|
assert bob_loaded.following_count == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "am_i_following reflects the actor's follow status" do
|
||||||
|
alice = user_fixture("alice7@example.com", "alice7")
|
||||||
|
bob = user_fixture("bob8@example.com", "bob8")
|
||||||
|
|
||||||
|
not_following =
|
||||||
|
User |> Ash.get!(bob.id, actor: alice, load: [:am_i_following], authorize?: false)
|
||||||
|
|
||||||
|
refute not_following.am_i_following
|
||||||
|
|
||||||
|
Follow
|
||||||
|
|> Ash.Changeset.for_create(:follow, %{following_id: bob.id}, actor: alice)
|
||||||
|
|> Ash.create!()
|
||||||
|
|
||||||
|
following =
|
||||||
|
User |> Ash.get!(bob.id, actor: alice, load: [:am_i_following], authorize?: false)
|
||||||
|
|
||||||
|
assert following.am_i_following
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── fixtures ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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 count_follows(follower_id, following_id) do
|
||||||
|
Follow
|
||||||
|
|> Ash.Query.filter(
|
||||||
|
Ash.Expr.expr(follower_id == ^follower_id and following_id == ^following_id)
|
||||||
|
)
|
||||||
|
|> Ash.read!(authorize?: false)
|
||||||
|
|> length()
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user