Skip to content
Closed
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ All notable changes to `doc` are documented here.

### Fixed

- Persist cancelling a like on a published document. The decrease endpoint now writes a
nonnegative `thumbUpCount` and returns the stored value; a missing publication is an error
rather than a false success. Duplicate cancellation against a zero count is a no-op. If the
request fails, the published-page button restores the previous count and liked state. Like
identity remains browser-local (`localStorage`); this change does not introduce server-side
per-user like records.
- Add an in-app back button to the TopBar that appears only after the first in-app navigation,
and scope the entry-document flag to `sessionStorage` so it survives SPA navigation but resets
on full page reload (#66).
Expand Down
107 changes: 107 additions & 0 deletions src/__tests__/api/thumb-up-routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
pubFindUnique: vi.fn(),
pubUpdate: vi.fn(),
pubUpdateMany: vi.fn(),
}))

vi.mock('server-only', () => ({}))
vi.mock('@/db/db', () => ({
db: {
pubDoc: {
findUnique: mocks.pubFindUnique,
update: mocks.pubUpdate,
updateMany: mocks.pubUpdateMany,
},
},
}))

import { PATCH as increase } from '@/app/api/pub/thumb-up/[publishId]/route'
import { PATCH as decrease } from '@/app/api/pub/thumb-up-decrease/[publishId]/route'

const params = { params: { publishId: 'published-doc' } }

describe('published document like persistence', () => {
beforeEach(() => {
Object.values(mocks).forEach((mock) => mock.mockReset())
})

it('persists a like increment', async () => {
mocks.pubUpdate.mockResolvedValue({
publishId: 'published-doc',
thumbUpCount: 2,
})

const response = await increase(new Request('http://doc.test/api/pub/thumb-up/published-doc'), params)

expect(mocks.pubUpdate).toHaveBeenCalledWith({
where: { publishId: 'published-doc' },
data: { thumbUpCount: { increment: 1 } },
})
await expect(response.json()).resolves.toEqual({
errno: 0,
data: { publishId: 'published-doc', thumbUpCount: 2 },
})
})

it('persists a like cancellation and returns the decremented count', async () => {
mocks.pubUpdateMany.mockResolvedValue({ count: 1 })
mocks.pubFindUnique.mockResolvedValue({
publishId: 'published-doc',
thumbUpCount: 1,
})

const response = await decrease(new Request('http://doc.test/api/pub/thumb-up-decrease/published-doc'), params)

expect(mocks.pubUpdateMany).toHaveBeenCalledWith({
where: { publishId: 'published-doc', thumbUpCount: { gt: 0 } },
data: { thumbUpCount: { decrement: 1 } },
})
await expect(response.json()).resolves.toEqual({
errno: 0,
data: { publishId: 'published-doc', thumbUpCount: 1 },
})
})

it('keeps a zero count nonnegative on duplicate cancellation', async () => {
mocks.pubUpdateMany.mockResolvedValue({ count: 0 })
mocks.pubFindUnique.mockResolvedValue({
publishId: 'published-doc',
thumbUpCount: 0,
})

const response = await decrease(new Request('http://doc.test/api/pub/thumb-up-decrease/published-doc'), params)

await expect(response.json()).resolves.toEqual({
errno: 0,
data: { publishId: 'published-doc', thumbUpCount: 0 },
})
})

it('does not report success when the publication is missing', async () => {
mocks.pubUpdateMany.mockResolvedValue({ count: 0 })
mocks.pubFindUnique.mockResolvedValue(null)

const response = await decrease(new Request('http://doc.test/api/pub/thumb-up-decrease/published-doc'), params)

await expect(response.json()).resolves.toEqual({
errno: -1,
msg: 'Publication not found',
})
})

it('does not expose storage errors from like cancellation', async () => {
mocks.pubUpdateMany.mockRejectedValue(new Error('database host and password'))
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})

const response = await decrease(new Request('http://doc.test/api/pub/thumb-up-decrease/published-doc'), params)

await expect(response.json()).resolves.toEqual({
errno: -1,
msg: 'Unable to update like count',
})
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
})
})
32 changes: 32 additions & 0 deletions src/__tests__/components/thumb-up-button.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import ThumbUpButton from '@/components/thumb-up-button'

const patch = vi.hoisted(() => vi.fn())

vi.mock('@/lib/ajax', () => ({ patch }))

describe('ThumbUpButton', () => {
afterEach(() => {
localStorage.clear()
patch.mockReset()
})

it('restores the count when like cancellation fails', async () => {
localStorage.setItem('thumbUp-pub-1', 'true')
patch.mockResolvedValue({ errno: -1, msg: 'Unable to update like count' })

render(<ThumbUpButton initialCount={4} publishId="pub-1" />)
const button = await screen.findByRole('button')
expect(button.textContent).toContain('4')

fireEvent.click(button)
await waitFor(() => {
expect(patch).toHaveBeenCalledWith('/api/pub/thumb-up-decrease/pub-1', {})
})
await waitFor(() => {
expect(screen.getByRole('button').textContent).toContain('4')
})
expect(localStorage.getItem('thumbUp-pub-1')).toBe('true')
})
})
48 changes: 31 additions & 17 deletions src/app/api/pub/thumb-up-decrease/[publishId]/route.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,37 @@
import { db } from '@/db/db'
import { genSuccessData, genErrorData } from '@/app/api/utils/gen-res-data'

export async function PATCH(request: Request, { params }: { params: { publishId: string } }) {
export async function PATCH(_request: Request, { params }: { params: { publishId: string } }) {
const { publishId } = params // `publishId` is publish url suffix
return Response.json(genSuccessData())

// try {
// const p = await db.pubDoc.update({
// where: {
// publishId,
// },
// data: {
// thumbUpCount: {
// decrement: 1, // Decrement the thumb up count by 1
// },
// },
// })
// return Response.json(genSuccessData(p))
// } catch (ex: any) {
// return Response.json(genErrorData(ex.message))
// }
try {
// Clamp at zero so duplicate cancellation cannot store a negative count.
// Identity is still browser-local (localStorage); this endpoint is
// idempotent with respect to a zero count, not with respect to a user.
await db.pubDoc.updateMany({
where: {
publishId,
thumbUpCount: {
gt: 0,
},
},
data: {
thumbUpCount: {
decrement: 1,
},
},
})
const publication = await db.pubDoc.findUnique({
where: {
publishId,
},
})
if (!publication) {
return Response.json(genErrorData('Publication not found'))
}
return Response.json(genSuccessData(publication))
} catch (error) {
console.error(error)
return Response.json(genErrorData('Unable to update like count'))
}
}
24 changes: 18 additions & 6 deletions src/components/thumb-up-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,29 @@ export default function ThumbUpButton(props: { initialCount: number; publishId:
const [thumbUpCount, setThumbUpCount] = useState(initialCount || 0)
const handleThumbUp = async () => {
if (loading) return // Prevent multiple clicks
const previousCount = thumbUpCount
const previousLiked = isLiked
if (isLiked) {
if (thumbUpCount <= 0) return // Prevent decrementing below zero
setThumbUpCount(thumbUpCount - 1)
setIsLiked(false)
localStorage.removeItem(STORE_KEY) // Remove publishId from localStorage
await patchData(`/api/pub/thumb-up-decrease/${publishId}`) // Decrease thumb up count in the backend
localStorage.removeItem(STORE_KEY)
const ok = await patchData(`/api/pub/thumb-up-decrease/${publishId}`)
if (!ok) {
setThumbUpCount(previousCount)
setIsLiked(true)
localStorage.setItem(STORE_KEY, 'true')
}
} else {
setThumbUpCount(thumbUpCount + 1)
setIsLiked(true)
localStorage.setItem(STORE_KEY, 'true') // store publishId in localStorage
await patchData(`/api/pub/thumb-up/${publishId}`) // Increase thumb up count in the backend
localStorage.setItem(STORE_KEY, 'true')
const ok = await patchData(`/api/pub/thumb-up/${publishId}`)
if (!ok) {
setThumbUpCount(previousCount)
setIsLiked(previousLiked)
localStorage.removeItem(STORE_KEY)
}
}
}

Expand All @@ -44,10 +56,10 @@ export default function ThumbUpButton(props: { initialCount: number; publishId:
if (response.errno !== 0) {
throw new Error('Network response was not ok')
}
const data = response.data
return data
return true
} catch (error) {
console.error('Error:', error)
return false
} finally {
setLoading(false)
}
Expand Down
Loading