From 2e57db895bc41a0bdab5a2dee840ab9754209656 Mon Sep 17 00:00:00 2001 From: waterbro-8 <2986253039@qq.com> Date: Thu, 17 Sep 2026 16:35:06 +0000 Subject: [PATCH 1/2] fix(pub): persist published document like cancellation Uncomment and complete the thumb-up decrease write so cancelling a like survives reload. Clamp at zero, return not-found instead of a false success, and cover the persisted readback path. Closes #59 --- CHANGELOG.md | 6 + src/__tests__/api/thumb-up-routes.test.ts | 107 ++++++++++++++++++ .../thumb-up-decrease/[publishId]/route.ts | 48 +++++--- 3 files changed, 144 insertions(+), 17 deletions(-) create mode 100644 src/__tests__/api/thumb-up-routes.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 194c29b..63423ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. - Replace hardcoded `timeAgo` strings with `next-intl` translation keys and return ISO-8601 `createdAt` from the version API so clients can format timestamps with `Intl.DateTimeFormat`. Remove unused `useTimeAgo` hook export and use real `messages/zh-cn.json` in `dt.test.ts` diff --git a/src/__tests__/api/thumb-up-routes.test.ts b/src/__tests__/api/thumb-up-routes.test.ts new file mode 100644 index 0000000..03d5090 --- /dev/null +++ b/src/__tests__/api/thumb-up-routes.test.ts @@ -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() + }) +}) diff --git a/src/app/api/pub/thumb-up-decrease/[publishId]/route.ts b/src/app/api/pub/thumb-up-decrease/[publishId]/route.ts index 8be9b83..e4e2e0e 100644 --- a/src/app/api/pub/thumb-up-decrease/[publishId]/route.ts +++ b/src/app/api/pub/thumb-up-decrease/[publishId]/route.ts @@ -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')) + } } From 3f6cda0dd553da0b6b444eb88671417dcbaf56ce Mon Sep 17 00:00:00 2001 From: waterbro-8 <318569545+waterbro-8@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:56:10 +0000 Subject: [PATCH 2/2] fix(pub): roll back published like UI when the request fails Keep the optimistic count, but restore the previous liked state and localStorage flag if increase or decrease returns an error. --- .../components/thumb-up-button.test.tsx | 32 +++++++++++++++++++ src/components/thumb-up-button.tsx | 24 ++++++++++---- 2 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/components/thumb-up-button.test.tsx diff --git a/src/__tests__/components/thumb-up-button.test.tsx b/src/__tests__/components/thumb-up-button.test.tsx new file mode 100644 index 0000000..26537de --- /dev/null +++ b/src/__tests__/components/thumb-up-button.test.tsx @@ -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() + 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') + }) +}) diff --git a/src/components/thumb-up-button.tsx b/src/components/thumb-up-button.tsx index 937f229..227593b 100644 --- a/src/components/thumb-up-button.tsx +++ b/src/components/thumb-up-button.tsx @@ -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) + } } } @@ -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) }