Compare commits

..

6 Commits

Author SHA1 Message Date
00af2350f4 some ralph changes that i tried i guess 2026-04-12 21:37:18 -04:00
df013731be feat: add user list pagination
UserList now uses useInfiniteQuery with offset pagination (20 per page)
and an IntersectionObserver scroll sentinel for infinite scroll.
Users sorted by username. Follows same pattern as Feed/UserFeed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 20:00:59 -04:00
c3ccab5fc5 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>
2026-04-12 19:57:35 -04:00
d7345ba234 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>
2026-04-12 19:54:43 -04:00
df8bc97bd2 fix: unlike noop stale struct + likes floor at 0
- unlike noop now reloads tweet from DB (same fix as like noop from prev loop)
- decrement_likes uses GREATEST(likes - 1, 0) to prevent negative counts
- add fix_plan.md to track remaining work

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 19:51:56 -04:00
4c67f38fa3 fix: make tweet_like tests pass
- Add authorize?: false to user_fixture so register_with_password
  bypasses policy check in test context
- Add require Ash.Query so Ash.Query.filter macro works in count_likes
- Replace nonexistent Ash.ForbiddenField.forbidden?/1 with match?/2
- Fix stale tweet struct in :like noop case by reloading from DB

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 19:48:23 -04:00
9 changed files with 473 additions and 15 deletions

3
.gitignore vendored
View File

@@ -38,3 +38,6 @@ mixer-*.tar
npm-debug.log
/assets/node_modules/
# Ralph code claude files
/.ralph/
.ralphrc

View File

@@ -2,7 +2,7 @@ import React, { useState, useRef, useEffect, useContext } from "react";
import { useQuery, useInfiniteQuery } from "@tanstack/react-query";
import { readUser, readTweet, buildCSRFHeaders } from "../ash_rpc";
import { AuthCtx } from "../context";
import { FEED_PAGE_SIZE } from "../constants";
import { FEED_PAGE_SIZE, USERS_PAGE_SIZE } from "../constants";
import { userDisplayLabel } from "../utils";
import { useFollowUser } from "../hooks";
import { Spinner, ErrorBanner, Avatar, ContextMenu } from "./ui";
@@ -83,23 +83,54 @@ export function UserCard({ user }: { user: User }) {
}
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"],
queryFn: async () => {
queryFn: async ({ pageParam }: { pageParam: number }) => {
const res = await readUser({
fields: ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing"],
sort: "username",
page: { limit: USERS_PAGE_SIZE, offset: pageParam },
headers: buildCSRFHeaders(),
});
if (!res.success) throw new Error("Failed to load users");
const users = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? [];
return users as User[];
const pageData = res.data as any;
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 (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) {
return (
@@ -116,6 +147,8 @@ export function UserList() {
{users.map((u) => (
<UserCard key={u.id} user={u} />
))}
<div ref={sentinelRef} style={{ height: "1px" }} />
{isFetchingNextPage && <Spinner />}
</div>
);
}

View File

@@ -1,2 +1,3 @@
export const FEED_PAGE_SIZE = 10;
export const COMMENTS_PAGE_SIZE = 10;
export const USERS_PAGE_SIZE = 20;

28
fix_plan.md Normal file
View 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)

View File

@@ -31,11 +31,11 @@ defmodule Mixer.Accounts.Follow do
accept [:following_id]
change relate_actor(:follower)
validate fn changeset, _context ->
follower_id = Ash.Changeset.get_attribute(changeset, :follower_id)
validate fn changeset, context ->
actor_id = context.actor && context.actor.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"}
else
:ok

View File

@@ -127,7 +127,7 @@ defmodule Mixer.Posts.Tweet do
increment_likes(tweet, context.actor)
{:noop, _like} ->
{:ok, tweet}
Ash.get(__MODULE__, tweet.id, authorize?: false)
{:error, error} ->
{:error, error}
@@ -148,7 +148,7 @@ defmodule Mixer.Posts.Tweet do
decrement_likes(tweet, context.actor)
{:noop, _like} ->
{:ok, tweet}
Ash.get(__MODULE__, tweet.id, authorize?: false)
{:error, error} ->
{:error, error}
@@ -166,7 +166,7 @@ defmodule Mixer.Posts.Tweet do
update :decrement_likes do
accept []
require_atomic? false
change atomic_update(:likes, expr(likes - 1))
change atomic_update(:likes, expr(fragment("GREATEST(? - 1, 0)", likes)))
end
end

View 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

View File

@@ -2,6 +2,7 @@ defmodule Mixer.Posts.TweetLikeTest do
use Mixer.DataCase, async: true
import Ash.Expr
require Ash.Query
alias Mixer.Accounts.User
alias Mixer.Posts.Tweet
@@ -24,14 +25,14 @@ defmodule Mixer.Posts.TweetLikeTest do
Tweet
|> 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
tweet_without_actor =
Tweet
|> 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
end
@@ -103,7 +104,7 @@ defmodule Mixer.Posts.TweetLikeTest do
password_confirmation: "password1234",
username: username
})
|> Ash.create!()
|> Ash.create!(authorize?: false)
end
defp tweet_fixture(user, content) do

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