Compare commits
6 Commits
88e84fcec5
...
00af2350f4
| Author | SHA1 | Date | |
|---|---|---|---|
| 00af2350f4 | |||
| df013731be | |||
| c3ccab5fc5 | |||
| d7345ba234 | |||
| df8bc97bd2 | |||
| 4c67f38fa3 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -38,3 +38,6 @@ mixer-*.tar
|
|||||||
npm-debug.log
|
npm-debug.log
|
||||||
/assets/node_modules/
|
/assets/node_modules/
|
||||||
|
|
||||||
|
# Ralph code claude files
|
||||||
|
/.ralph/
|
||||||
|
.ralphrc
|
||||||
@@ -2,7 +2,7 @@ import React, { useState, useRef, useEffect, useContext } from "react";
|
|||||||
import { useQuery, useInfiniteQuery } from "@tanstack/react-query";
|
import { useQuery, useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { readUser, readTweet, buildCSRFHeaders } from "../ash_rpc";
|
import { readUser, readTweet, buildCSRFHeaders } from "../ash_rpc";
|
||||||
import { AuthCtx } from "../context";
|
import { AuthCtx } from "../context";
|
||||||
import { FEED_PAGE_SIZE } from "../constants";
|
import { FEED_PAGE_SIZE, USERS_PAGE_SIZE } from "../constants";
|
||||||
import { userDisplayLabel } from "../utils";
|
import { userDisplayLabel } from "../utils";
|
||||||
import { useFollowUser } from "../hooks";
|
import { useFollowUser } from "../hooks";
|
||||||
import { Spinner, ErrorBanner, Avatar, ContextMenu } from "./ui";
|
import { Spinner, ErrorBanner, Avatar, ContextMenu } from "./ui";
|
||||||
@@ -83,23 +83,54 @@ export function UserCard({ user }: { user: User }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function UserList() {
|
export function UserList() {
|
||||||
const { data, isLoading, isError, error } = useQuery({
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
} = useInfiniteQuery({
|
||||||
queryKey: ["users"],
|
queryKey: ["users"],
|
||||||
queryFn: async () => {
|
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||||
const res = await readUser({
|
const res = await readUser({
|
||||||
fields: ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing"],
|
fields: ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing"],
|
||||||
|
sort: "username",
|
||||||
|
page: { limit: USERS_PAGE_SIZE, offset: pageParam },
|
||||||
headers: buildCSRFHeaders(),
|
headers: buildCSRFHeaders(),
|
||||||
});
|
});
|
||||||
if (!res.success) throw new Error("Failed to load users");
|
if (!res.success) throw new Error("Failed to load users");
|
||||||
const users = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? [];
|
const pageData = res.data as any;
|
||||||
return users as User[];
|
const users: User[] = Array.isArray(pageData) ? pageData : (pageData?.results ?? []);
|
||||||
|
const hasMore: boolean = Array.isArray(pageData) ? false : (pageData?.hasMore ?? false);
|
||||||
|
return { users, hasMore, nextOffset: pageParam + USERS_PAGE_SIZE };
|
||||||
},
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextOffset : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sentinelRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 },
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
if (isLoading) return <Spinner />;
|
if (isLoading) return <Spinner />;
|
||||||
if (isError) return <ErrorBanner message={(error as Error)?.message ?? "Could not load users"} />;
|
if (isError) return <ErrorBanner message={(error as Error)?.message ?? "Could not load users"} />;
|
||||||
|
|
||||||
const users = data ?? [];
|
const users = data?.pages.flatMap((p) => p.users) ?? [];
|
||||||
|
|
||||||
if (users.length === 0) {
|
if (users.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -116,6 +147,8 @@ export function UserList() {
|
|||||||
{users.map((u) => (
|
{users.map((u) => (
|
||||||
<UserCard key={u.id} user={u} />
|
<UserCard key={u.id} user={u} />
|
||||||
))}
|
))}
|
||||||
|
<div ref={sentinelRef} style={{ height: "1px" }} />
|
||||||
|
{isFetchingNextPage && <Spinner />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export const FEED_PAGE_SIZE = 10;
|
export const FEED_PAGE_SIZE = 10;
|
||||||
export const COMMENTS_PAGE_SIZE = 10;
|
export const COMMENTS_PAGE_SIZE = 10;
|
||||||
|
export const USERS_PAGE_SIZE = 20;
|
||||||
|
|||||||
28
fix_plan.md
Normal file
28
fix_plan.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Fix Plan
|
||||||
|
|
||||||
|
## Completed
|
||||||
|
|
||||||
|
- [x] `tweet_like` tests: `user_fixture` missing `authorize?: false`, `Ash.Query.filter` needed `require Ash.Query`, `Ash.ForbiddenField.forbidden?/1` doesn't exist (use `match?`), `like` noop returned stale tweet struct → fixed all
|
||||||
|
|
||||||
|
## In Progress / Next
|
||||||
|
|
||||||
|
- [x] `unlike` noop returns stale tweet struct — same issue as `like` noop; reload from DB
|
||||||
|
- [x] `decrement_likes` can go below 0 — use `GREATEST(likes - 1, 0)` via SQL fragment
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
- [x] User list pagination — useInfiniteQuery + scroll sentinel, USERS_PAGE_SIZE=20, sorted by username
|
||||||
|
- [ ] 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
|
||||||
|
- [x] Tweet creation, update, delete, comment tests (13 tests)
|
||||||
|
- [ ] Missing test coverage: auth flows
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Stack: Elixir/Phoenix + Ash Framework + React/TypeScript
|
||||||
|
- Tests: `mix test` — 10 tests, all should pass
|
||||||
|
- Build: `mix precommit` alias runs compile + test + format checks
|
||||||
|
- No ClickHouse in test env (expected, non-fatal errors in test output)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ defmodule Mixer.Posts.Tweet do
|
|||||||
increment_likes(tweet, context.actor)
|
increment_likes(tweet, context.actor)
|
||||||
|
|
||||||
{:noop, _like} ->
|
{:noop, _like} ->
|
||||||
{:ok, tweet}
|
Ash.get(__MODULE__, tweet.id, authorize?: false)
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
{:error, error}
|
{:error, error}
|
||||||
@@ -148,7 +148,7 @@ defmodule Mixer.Posts.Tweet do
|
|||||||
decrement_likes(tweet, context.actor)
|
decrement_likes(tweet, context.actor)
|
||||||
|
|
||||||
{:noop, _like} ->
|
{:noop, _like} ->
|
||||||
{:ok, tweet}
|
Ash.get(__MODULE__, tweet.id, authorize?: false)
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
{:error, error}
|
{:error, error}
|
||||||
@@ -166,7 +166,7 @@ defmodule Mixer.Posts.Tweet do
|
|||||||
update :decrement_likes do
|
update :decrement_likes do
|
||||||
accept []
|
accept []
|
||||||
require_atomic? false
|
require_atomic? false
|
||||||
change atomic_update(:likes, expr(likes - 1))
|
change atomic_update(:likes, expr(fragment("GREATEST(? - 1, 0)", likes)))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
@@ -2,6 +2,7 @@ defmodule Mixer.Posts.TweetLikeTest do
|
|||||||
use Mixer.DataCase, async: true
|
use Mixer.DataCase, async: true
|
||||||
|
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
alias Mixer.Accounts.User
|
alias Mixer.Accounts.User
|
||||||
alias Mixer.Posts.Tweet
|
alias Mixer.Posts.Tweet
|
||||||
@@ -24,14 +25,14 @@ defmodule Mixer.Posts.TweetLikeTest do
|
|||||||
Tweet
|
Tweet
|
||||||
|> Ash.get!(tweet.id, actor: user, load: [:liked_by_me], authorize?: false)
|
|> Ash.get!(tweet.id, actor: user, load: [:liked_by_me], authorize?: false)
|
||||||
|
|
||||||
refute Ash.ForbiddenField.forbidden?(tweet_for_actor.liked_by_me)
|
refute match?(%Ash.ForbiddenField{}, tweet_for_actor.liked_by_me)
|
||||||
assert tweet_for_actor.liked_by_me
|
assert tweet_for_actor.liked_by_me
|
||||||
|
|
||||||
tweet_without_actor =
|
tweet_without_actor =
|
||||||
Tweet
|
Tweet
|
||||||
|> Ash.get!(tweet.id, load: [:liked_by_me], authorize?: false)
|
|> Ash.get!(tweet.id, load: [:liked_by_me], authorize?: false)
|
||||||
|
|
||||||
refute Ash.ForbiddenField.forbidden?(tweet_without_actor.liked_by_me)
|
refute match?(%Ash.ForbiddenField{}, tweet_without_actor.liked_by_me)
|
||||||
refute tweet_without_actor.liked_by_me
|
refute tweet_without_actor.liked_by_me
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -103,7 +104,7 @@ defmodule Mixer.Posts.TweetLikeTest do
|
|||||||
password_confirmation: "password1234",
|
password_confirmation: "password1234",
|
||||||
username: username
|
username: username
|
||||||
})
|
})
|
||||||
|> Ash.create!()
|
|> Ash.create!(authorize?: false)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp tweet_fixture(user, content) do
|
defp tweet_fixture(user, content) do
|
||||||
|
|||||||
220
test/mixer/posts/tweet_test.exs
Normal file
220
test/mixer/posts/tweet_test.exs
Normal 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
|
||||||
Reference in New Issue
Block a user