From 81893e561e98afb6b4ed607adbc7d734a18f5b5b Mon Sep 17 00:00:00 2001 From: Yonatan Hen Date: Sat, 1 Aug 2026 15:09:44 +0300 Subject: [PATCH] feat(posts): make the like button a real toggle PostDto now carries a per-viewer liked flag (batched lookup, never one query per post) so the feed and post page know whether the current reader already liked a post. LikeButton picks like vs unlike based on that flag and fills the heart when liked; useUnlikePost mirrors useLikePost's optimistic update/rollback, just decrementing and flipping liked false instead of true. --- apps/client/src/api/posts.ts | 2 + .../components/patterns/LikeButton.test.tsx | 62 +++++++++++ .../src/components/patterns/LikeButton.tsx | 35 ++++-- apps/client/src/hooks/use-likes.test.tsx | 100 +++++++++++++++++- apps/client/src/hooks/use-likes.ts | 45 +++++++- apps/client/src/pages/PostPage.tsx | 2 +- apps/server/src/lib/services/post.test.ts | 44 ++++++++ apps/server/src/lib/services/post.ts | 24 ++++- 8 files changed, 301 insertions(+), 13 deletions(-) create mode 100644 apps/client/src/components/patterns/LikeButton.test.tsx diff --git a/apps/client/src/api/posts.ts b/apps/client/src/api/posts.ts index d09bc40e0..d28ea5f3d 100644 --- a/apps/client/src/api/posts.ts +++ b/apps/client/src/api/posts.ts @@ -12,6 +12,8 @@ export type Post = { author: { id: string; username: string } tags: string[] likeCount: number + /** This viewer's own like status. Absent for an anonymous viewer. */ + liked?: boolean coverImage?: string /** Delivery URL derived server-side from coverImage. Absent when there is no cover. */ coverUrl?: string diff --git a/apps/client/src/components/patterns/LikeButton.test.tsx b/apps/client/src/components/patterns/LikeButton.test.tsx new file mode 100644 index 000000000..3b41e23f6 --- /dev/null +++ b/apps/client/src/components/patterns/LikeButton.test.tsx @@ -0,0 +1,62 @@ +// The root vitest.config.ts declares no setupFiles, so apps/client/src/test/setup.ts +// never runs — every client test file wires jest-dom and cleanup itself, as +// apps/client/src/components/ui/button.test.tsx does. +import '@testing-library/jest-dom/vitest' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { LikeButton } from './LikeButton.js' +import type { ReactElement } from 'react' + +function renderWithClient(ui: ReactElement) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render({ui}) +} + +describe('LikeButton', () => { + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + }) + + it('shows an outline heart and calls the like (PUT) mutation when not liked', async () => { + vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {}))) // never resolves + renderWithClient() + + const button = screen.getByRole('button') + expect(button.querySelector('svg')).not.toHaveClass('fill-current') + + fireEvent.click(button) + await waitFor(() => + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/posts/s/likes'), + expect.objectContaining({ method: 'PUT' }), + ), + ) + }) + + it('shows a filled heart and calls the unlike (DELETE) mutation when liked', async () => { + vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {}))) // never resolves + renderWithClient() + + const button = screen.getByRole('button') + expect(button.querySelector('svg')).toHaveClass('fill-current') + + fireEvent.click(button) + await waitFor(() => + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/posts/s/likes'), + expect.objectContaining({ method: 'DELETE' }), + ), + ) + }) + + it('defaults to unliked when the `liked` prop is omitted (anonymous-viewer DTO)', () => { + vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {}))) + renderWithClient() + + expect(screen.getByRole('button').querySelector('svg')).not.toHaveClass('fill-current') + }) +}) diff --git a/apps/client/src/components/patterns/LikeButton.tsx b/apps/client/src/components/patterns/LikeButton.tsx index 0ad337371..34e8ce022 100644 --- a/apps/client/src/components/patterns/LikeButton.tsx +++ b/apps/client/src/components/patterns/LikeButton.tsx @@ -1,18 +1,37 @@ import { Heart } from 'lucide-react' -import { useLikePost } from '../../hooks/use-likes.js' +import { useLikePost, useUnlikePost } from '../../hooks/use-likes.js' import { Button } from '../ui/button.js' /** - * No `liked` prop: `PostDto` carries no per-viewer "did I like this" flag, so - * the button shows the raw count and lets the mutation run either way. Like is - * idempotent server-side (unique index on `(user, post)`), so a second click is - * a no-op 200, not a double count. + * `liked` picks which mutation a click fires and fills the heart when true. + * Both directions are idempotent server-side, so a double click race is a + * no-op, not a double count or a stuck toggle. */ -export function LikeButton({ slug, likeCount }: { slug: string; likeCount: number }) { +export function LikeButton({ + slug, + likeCount, + liked = false, +}: { + slug: string + likeCount: number + liked?: boolean +}) { const like = useLikePost(slug) + const unlike = useUnlikePost(slug) + const pending = like.isPending || unlike.isPending + return ( - ) } diff --git a/apps/client/src/hooks/use-likes.test.tsx b/apps/client/src/hooks/use-likes.test.tsx index cf32f9af5..19234b89d 100644 --- a/apps/client/src/hooks/use-likes.test.tsx +++ b/apps/client/src/hooks/use-likes.test.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { renderHook, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { useLikePost } from './use-likes.js' +import { useLikePost, useUnlikePost } from './use-likes.js' import { queryKeys } from '../lib/query-client.js' import type { Post } from '../api/posts.js' @@ -46,6 +46,16 @@ describe('useLikePost', () => { ) }) + it('flips liked to true immediately, before the request resolves', async () => { + vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {}))) // never resolves + const { client, wrapper } = makeWrapper() + const { result } = renderHook(() => useLikePost('s'), { wrapper }) + result.current.mutate() + await waitFor(() => + expect(client.getQueryData(queryKeys.posts.detail('s'))?.liked).toBe(true), + ) + }) + it('rolls back likeCount if the request fails', async () => { vi.stubGlobal( 'fetch', @@ -93,3 +103,91 @@ describe('useLikePost', () => { expect(list?.[0]?.likeCount).toBe(2) }) }) + +const likedPost: Post = { ...post, liked: true } + +function makeLikedWrapper() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + client.setQueryData(queryKeys.posts.detail('s'), likedPost) + client.setQueryData(queryKeys.posts.list({}), [likedPost]) + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ) + return { client, wrapper } +} + +// Mirrors the useLikePost suite above: same optimistic/rollback shape, just +// starting from a liked post and moving the other way. +describe('useUnlikePost', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('decrements likeCount immediately, before the request resolves', async () => { + vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {}))) // never resolves + const { client, wrapper } = makeLikedWrapper() + const { result } = renderHook(() => useUnlikePost('s'), { wrapper }) + result.current.mutate() + await waitFor(() => + expect(client.getQueryData(queryKeys.posts.detail('s'))?.likeCount).toBe(1), + ) + }) + + it('flips liked to false immediately, before the request resolves', async () => { + vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {}))) // never resolves + const { client, wrapper } = makeLikedWrapper() + const { result } = renderHook(() => useUnlikePost('s'), { wrapper }) + result.current.mutate() + await waitFor(() => + expect(client.getQueryData(queryKeys.posts.detail('s'))?.liked).toBe(false), + ) + }) + + it('rolls back likeCount and liked if the request fails', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ error: { message: 'nope' } }), { status: 500 }), + ), + ) + const { client, wrapper } = makeLikedWrapper() + const { result } = renderHook(() => useUnlikePost('s'), { wrapper }) + result.current.mutate() + await waitFor(() => expect(result.current.isError).toBe(true)) + const detail = client.getQueryData(queryKeys.posts.detail('s')) + expect(detail?.likeCount).toBe(2) + expect(detail?.liked).toBe(true) + }) + + it("also decrements the feed's cached copy of this post", async () => { + vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {}))) // never resolves + const { client, wrapper } = makeLikedWrapper() + const { result } = renderHook(() => useUnlikePost('s'), { wrapper }) + result.current.mutate() + await waitFor(() => { + const list = client.getQueryData(queryKeys.posts.list({})) + expect(list?.[0]?.likeCount).toBe(1) + expect(list?.[0]?.liked).toBe(false) + }) + }) + + it("rolls back the feed's cached copy too, alongside the detail cache", async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ error: { message: 'nope' } }), { status: 500 }), + ), + ) + const { client, wrapper } = makeLikedWrapper() + const { result } = renderHook(() => useUnlikePost('s'), { wrapper }) + result.current.mutate() + await waitFor(() => expect(result.current.isError).toBe(true)) + const list = client.getQueryData(queryKeys.posts.list({})) + expect(list?.[0]?.likeCount).toBe(2) + expect(list?.[0]?.liked).toBe(true) + }) +}) diff --git a/apps/client/src/hooks/use-likes.ts b/apps/client/src/hooks/use-likes.ts index 5d2fa12bc..3982325f8 100644 --- a/apps/client/src/hooks/use-likes.ts +++ b/apps/client/src/hooks/use-likes.ts @@ -28,6 +28,7 @@ export function useLikePost(slug: string) { queryClient.setQueryData(queryKeys.posts.detail(slug), { ...previousDetail, likeCount: previousDetail.likeCount + 1, + liked: true, }) } @@ -36,7 +37,9 @@ export function useLikePost(slug: string) { // rollback snapshot — this must be captured separately, first. const previousLists = queryClient.getQueriesData({ queryKey: queryKeys.posts.lists }) queryClient.setQueriesData({ queryKey: queryKeys.posts.lists }, (old) => - old?.map((post) => (post.slug === slug ? { ...post, likeCount: post.likeCount + 1 } : post)), + old?.map((post) => + post.slug === slug ? { ...post, likeCount: post.likeCount + 1, liked: true } : post, + ), ) return { previousDetail, previousLists } @@ -52,3 +55,43 @@ export function useLikePost(slug: string) { onSettled: () => queryClient.invalidateQueries({ queryKey: queryKeys.posts.all }), }) } + +/** + * Mirrors useLikePost: same optimistic-update/rollback shape, but decrements + * likeCount and flips `liked` to false instead of true. + */ +export function useUnlikePost(slug: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: () => postsApi.unlike(slug), + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: queryKeys.posts.all }) + + const previousDetail = queryClient.getQueryData(queryKeys.posts.detail(slug)) + if (previousDetail) { + queryClient.setQueryData(queryKeys.posts.detail(slug), { + ...previousDetail, + likeCount: previousDetail.likeCount - 1, + liked: false, + }) + } + + const previousLists = queryClient.getQueriesData({ queryKey: queryKeys.posts.lists }) + queryClient.setQueriesData({ queryKey: queryKeys.posts.lists }, (old) => + old?.map((post) => + post.slug === slug ? { ...post, likeCount: post.likeCount - 1, liked: false } : post, + ), + ) + + return { previousDetail, previousLists } + }, + onError: (_err, _vars, context) => { + if (context?.previousDetail) { + queryClient.setQueryData(queryKeys.posts.detail(slug), context.previousDetail) + } + context?.previousLists?.forEach(([key, data]) => queryClient.setQueryData(key, data)) + }, + onSettled: () => queryClient.invalidateQueries({ queryKey: queryKeys.posts.all }), + }) +} diff --git a/apps/client/src/pages/PostPage.tsx b/apps/client/src/pages/PostPage.tsx index af0cf9f39..40c747f2f 100644 --- a/apps/client/src/pages/PostPage.tsx +++ b/apps/client/src/pages/PostPage.tsx @@ -69,7 +69,7 @@ export function PostPage() { )}
- + {isOwner && ( <> diff --git a/apps/server/src/lib/services/post.test.ts b/apps/server/src/lib/services/post.test.ts index 6256620f8..8b9d65095 100644 --- a/apps/server/src/lib/services/post.test.ts +++ b/apps/server/src/lib/services/post.test.ts @@ -85,6 +85,32 @@ describe('postService.list', () => { expect(post!.body).toBe('Para one.\n\nPara two.') expect(JSON.stringify(post)).not.toContain('Para three') }) + + it('omits liked for an anonymous reader', async () => { + await create() + expect((await postService.list())[0]!.liked).toBeUndefined() + }) + + it('marks liked true for a signed-in reader who liked the post', async () => { + const post = await create() + const reader = await signUpReader() + await LikeModel.create({ user: reader._id, post: new Types.ObjectId(post.id) }) + expect((await postService.list(reader._id.toString()))[0]!.liked).toBe(true) + }) + + it('marks liked false for a signed-in reader who has not liked the post', async () => { + await create() + const reader = await signUpReader() + expect((await postService.list(reader._id.toString()))[0]!.liked).toBe(false) + }) + + it("does not mark liked true from another reader's like", async () => { + const post = await create() + const otherReader = await signUpReader() + await LikeModel.create({ user: otherReader._id, post: new Types.ObjectId(post.id) }) + const reader = await UserModel.create({ username: 'reader2', email: 'r2@example.com', password: 'x' }) + expect((await postService.list(reader._id.toString()))[0]!.liked).toBe(false) + }) }) describe('postService.list — search and tag filters', () => { @@ -225,6 +251,24 @@ describe('postService.getBySlug — THE gating rule (spec §6)', () => { it('throws NotFoundError for an unknown slug', async () => { await expect(postService.getBySlug('nope', undefined)).rejects.toThrow(NotFoundError) }) + + it('omits liked for an anonymous reader', async () => { + const { slug } = await create() + expect((await postService.getBySlug(slug, undefined)).liked).toBeUndefined() + }) + + it('marks liked true when the viewer has liked the post', async () => { + const { id, slug } = await create() + const reader = await signUpReader() + await LikeModel.create({ user: reader._id, post: new Types.ObjectId(id) }) + expect((await postService.getBySlug(slug, reader._id.toString())).liked).toBe(true) + }) + + it('marks liked false when the viewer has not liked the post', async () => { + const { slug } = await create() + const reader = await signUpReader() + expect((await postService.getBySlug(slug, reader._id.toString())).liked).toBe(false) + }) }) describe('postService.update', () => { diff --git a/apps/server/src/lib/services/post.ts b/apps/server/src/lib/services/post.ts index 8fb232750..c9d6fba18 100644 --- a/apps/server/src/lib/services/post.ts +++ b/apps/server/src/lib/services/post.ts @@ -26,6 +26,8 @@ export type PostDto = { author: PostAuthor tags: string[] likeCount: number + /** This viewer's own like status. Present only when there's a viewerId — see toDto. */ + liked?: boolean coverImage?: string /** Derived from `coverImage` at serialization time — see deliveryUrl. */ coverUrl?: string @@ -54,7 +56,7 @@ function isPopulated(author: unknown): author is PopulatedAuthor { function toDto( post: HydratedDocument, likeCount: number, - { full, gated }: { full: boolean; gated: boolean }, + { full, gated, liked }: { full: boolean; gated: boolean; liked?: boolean }, ): PostDto { const author = post.author // REGRESSION GUARD (legacy postsList.jsx:12): `body` is required by the schema, @@ -73,6 +75,7 @@ function toDto( : { id: String(author), username: '' }, tags: post.tags ?? [], likeCount, + liked, coverImage: post.coverImage ?? undefined, coverUrl: post.coverImage ? deliveryUrl(post.coverImage) : undefined, createdAt: post.createdAt, @@ -98,6 +101,20 @@ async function countLikes(postId: Types.ObjectId): Promise { return LikeModel.countDocuments({ post: postId }) } +/** + * One query for the whole page, not one per post: which of these post ids has + * this viewer liked. Returns undefined for an anonymous viewer so callers can + * tell "no session" apart from "liked none of them". + */ +async function likedPostIds( + postIds: Types.ObjectId[], + viewerId?: string, +): Promise | undefined> { + if (!viewerId || postIds.length === 0) return undefined + const likes = await LikeModel.find({ user: viewerId, post: { $in: postIds } }).select('post') + return new Set(likes.map((like) => like.post.toString())) +} + export const postService = { /** * The feed. Teaser bodies ALWAYS — a list endpoint never ships full bodies, @@ -140,12 +157,14 @@ export const postService = { // in a stable, meaningful order rather than whatever the index yields. query.sort(term ? { score: { $meta: 'textScore' }, createdAt: -1 } : { createdAt: -1 }) const posts = await query + const liked = await likedPostIds(posts.map((p) => p._id), viewerId) return Promise.all( posts.map(async (p) => toDto(p, await countLikes(p._id), { full: false, gated: !viewerId, + liked: liked?.has(p._id.toString()), }), ), ) @@ -156,7 +175,8 @@ export const postService = { if (!post) throw new NotFoundError('Post not found.') const full = Boolean(viewerId) - return toDto(post, await countLikes(post._id), { full, gated: !full }) + const liked = await likedPostIds([post._id], viewerId) + return toDto(post, await countLikes(post._id), { full, gated: !full, liked: liked?.has(post._id.toString()) }) }, async create(input: CreatePost, authorId: string): Promise {