Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/client/src/api/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions apps/client/src/components/patterns/LikeButton.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<QueryClientProvider client={client}>{ui}</QueryClientProvider>)
}

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(<LikeButton slug="s" likeCount={2} liked={false} />)

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(<LikeButton slug="s" likeCount={3} liked={true} />)

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(<LikeButton slug="s" likeCount={0} />)

expect(screen.getByRole('button').querySelector('svg')).not.toHaveClass('fill-current')
})
})
35 changes: 27 additions & 8 deletions apps/client/src/components/patterns/LikeButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Button variant="outline" size="sm" onClick={() => like.mutate()} disabled={like.isPending}>
<Heart className="mr-1 size-4" /> {likeCount}
<Button
variant="outline"
size="sm"
onClick={() => (liked ? unlike.mutate() : like.mutate())}
disabled={pending}
aria-pressed={liked}
>
<Heart
className={liked ? 'mr-1 size-4 fill-current text-[var(--primary)]' : 'mr-1 size-4'}
/>{' '}
{likeCount}
</Button>
)
}
100 changes: 99 additions & 1 deletion apps/client/src/hooks/use-likes.test.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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<Post>(queryKeys.posts.detail('s'))?.liked).toBe(true),
)
})

it('rolls back likeCount if the request fails', async () => {
vi.stubGlobal(
'fetch',
Expand Down Expand Up @@ -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 }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
)
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<Post>(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<Post>(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<Post>(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<Post[]>(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<Post[]>(queryKeys.posts.list({}))
expect(list?.[0]?.likeCount).toBe(2)
expect(list?.[0]?.liked).toBe(true)
})
})
45 changes: 44 additions & 1 deletion apps/client/src/hooks/use-likes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function useLikePost(slug: string) {
queryClient.setQueryData<Post>(queryKeys.posts.detail(slug), {
...previousDetail,
likeCount: previousDetail.likeCount + 1,
liked: true,
})
}

Expand All @@ -36,7 +37,9 @@ export function useLikePost(slug: string) {
// rollback snapshot — this must be captured separately, first.
const previousLists = queryClient.getQueriesData<Post[]>({ queryKey: queryKeys.posts.lists })
queryClient.setQueriesData<Post[]>({ 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 }
Expand All @@ -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<Post>(queryKeys.posts.detail(slug))
if (previousDetail) {
queryClient.setQueryData<Post>(queryKeys.posts.detail(slug), {
...previousDetail,
likeCount: previousDetail.likeCount - 1,
liked: false,
})
}

const previousLists = queryClient.getQueriesData<Post[]>({ queryKey: queryKeys.posts.lists })
queryClient.setQueriesData<Post[]>({ 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 }),
})
}
2 changes: 1 addition & 1 deletion apps/client/src/pages/PostPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export function PostPage() {
)}

<div className="flex items-center gap-4 border-t border-[var(--border)] pt-5 text-sm text-[var(--muted-foreground)]">
<LikeButton slug={post.slug} likeCount={post.likeCount} />
<LikeButton slug={post.slug} likeCount={post.likeCount} liked={post.liked} />
{isOwner && (
<>
<Link to={`/blog/${post.slug}/edit`} className="underline">
Expand Down
44 changes: 44 additions & 0 deletions apps/server/src/lib/services/post.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading
Loading