diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..d67f3748 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +node-linker=hoisted diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 003244c3..7e266301 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,7 +74,7 @@ importers: version: 3.4.7 ethers: specifier: ^6.12.0 - version: 6.16.0 + version: 6.17.0 framer-motion: specifier: ^12.23.0 version: 12.40.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -188,10 +188,10 @@ importers: version: 7.4.1(@types/babel__core@7.20.5)(webpack@5.107.2(lightningcss@1.32.0)(postcss@8.5.15)) y-websocket: specifier: ^3.0.0 - version: 3.0.0(yjs@13.6.30) + version: 3.0.0(yjs@13.6.31) yjs: specifier: ^13.6.30 - version: 13.6.30 + version: 13.6.31 zod: specifier: ^3.25.75 version: 3.25.76 @@ -3293,6 +3293,9 @@ packages: rollup: optional: true + '@rollup/pluginutils@5.4.0': + resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} + engines: {node: '>=14.0.0'} '@rollup/pluginutils@5.3.0': resolution: { @@ -3760,6 +3763,9 @@ packages: integrity: sha512-c0F/DzB30GV0tQAfWvbfGEZmzyTPics286IV+XRafj2j7UKur2yvEh5m3ORd79+KWleIV4dbGjfXTJ3MEz2LGg==, } + '@stencil/core@4.43.5': + resolution: {integrity: sha512-cgWD+GeuvJpTe1WQn40p02+BJ2j0j1YJ17GdkF2qKIQ23s2e3Zivq5yISXS3dcuV6oUJFN93jprdk+nk/sq99Q==} + engines: {node: '>=16.0.0', npm: '>=7.10.0'} '@stencil/core@4.43.4': resolution: { @@ -6874,6 +6880,8 @@ packages: engines: { node: '>=0.10.0' } hasBin: true + electron-to-chromium@1.5.364: + resolution: {integrity: sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==} electron-to-chromium@1.5.363: resolution: { @@ -6943,6 +6951,9 @@ packages: } engines: { node: '>=10.2.0' } + enhanced-resolve@5.22.1: + resolution: {integrity: sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==} + engines: {node: '>=10.13.0'} enhanced-resolve@5.22.0: resolution: { @@ -7139,6 +7150,9 @@ packages: eslint-plugin-import-x: optional: true + eslint-module-utils@2.13.0: + resolution: {integrity: sha512-bLohSkT6469rRs8czj0tLTD8vaeIS/whvPRJVjDr7IuoTT1k5DYDERlNycjDj/HkOlvQdYurmfZ/g3fG5bgeLQ==} + engines: {node: '>=4'} eslint-module-utils@2.12.1: resolution: { @@ -7346,6 +7360,9 @@ packages: } engines: { node: '>=20' } + ethers@6.17.0: + resolution: {integrity: sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==} + engines: {node: '>=14.0.0'} ethers@6.16.0: resolution: { @@ -7929,6 +7946,9 @@ packages: integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==, } + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} hasown@2.0.3: resolution: { @@ -11636,6 +11656,9 @@ packages: integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==, } + tinyexec@1.2.3: + resolution: {integrity: sha512-g62dB+w1/OEFnPvmX0yd/HnetYITOL+1nJW7kitOycOeAvmbWC/nu0fwmmQ/kupNojqExzyC/T++pST/jRJ2mQ==} + engines: {node: '>=18'} tinyexec@1.2.2: resolution: { @@ -11840,6 +11863,9 @@ packages: } engines: { node: '>= 0.4' } + typed-array-length@1.0.8: + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} + engines: {node: '>= 0.4'} typed-array-length@1.0.7: resolution: { @@ -12823,6 +12849,9 @@ packages: integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==, } + yjs@13.6.31: + resolution: {integrity: sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} yjs@13.6.30: resolution: { @@ -12913,8 +12942,6 @@ packages: snapshots: '@adobe/css-tools@4.5.0': {} - '@adraffy/ens-normalize@1.10.1': {} - '@adraffy/ens-normalize@1.11.1': {} '@alloc/quick-lru@5.2.0': {} @@ -15182,7 +15209,7 @@ snapshots: dependencies: '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.29.7 - '@rollup/pluginutils': 5.3.0(rollup@4.60.4) + '@rollup/pluginutils': 5.4.0(rollup@4.60.4) optionalDependencies: '@types/babel__core': 7.20.5 rollup: 4.60.4 @@ -15191,7 +15218,7 @@ snapshots: '@rollup/plugin-node-resolve@16.0.3(rollup@4.60.4)': dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.60.4) + '@rollup/pluginutils': 5.4.0(rollup@4.60.4) '@types/resolve': 1.20.2 deepmerge: 4.3.1 is-module: 1.0.0 @@ -15201,7 +15228,7 @@ snapshots: '@rollup/plugin-replace@6.0.3(rollup@4.60.4)': dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.60.4) + '@rollup/pluginutils': 5.4.0(rollup@4.60.4) magic-string: 0.30.21 optionalDependencies: rollup: 4.60.4 @@ -15214,7 +15241,7 @@ snapshots: optionalDependencies: rollup: 4.60.4 - '@rollup/pluginutils@5.3.0(rollup@4.60.4)': + '@rollup/pluginutils@5.4.0(rollup@4.60.4)': dependencies: '@types/estree': 1.0.9 estree-walker: 2.0.2 @@ -15394,7 +15421,7 @@ snapshots: '@stacks/connect-ui@8.1.2': dependencies: - '@stencil/core': 4.43.4 + '@stencil/core': 4.43.5 '@stacks/connect@8.2.6(@types/react@18.3.29)(lit@3.3.0)(react@18.3.1)(typescript@5.9.3)(zod@3.25.76)': dependencies: @@ -15539,7 +15566,7 @@ snapshots: transitivePeerDependencies: - encoding - '@stencil/core@4.43.4': + '@stencil/core@4.43.5': optionalDependencies: '@rollup/rollup-darwin-arm64': 4.44.0 '@rollup/rollup-darwin-x64': 4.44.0 @@ -15617,7 +15644,7 @@ snapshots: '@tailwindcss/node@4.3.0': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.22.0 + enhanced-resolve: 5.22.1 jiti: 2.7.0 lightningcss: 1.32.0 magic-string: 0.30.21 @@ -17174,7 +17201,7 @@ snapshots: dependencies: baseline-browser-mapping: 2.10.32 caniuse-lite: 1.0.30001793 - electron-to-chromium: 1.5.363 + electron-to-chromium: 1.5.364 node-releases: 2.0.46 update-browserslist-db: 1.2.3(browserslist@4.28.2) @@ -17726,7 +17753,7 @@ snapshots: dependencies: jake: 10.9.4 - electron-to-chromium@1.5.363: {} + electron-to-chromium@1.5.364: {} elliptic@6.6.1: dependencies: @@ -17784,7 +17811,7 @@ snapshots: - supports-color - utf-8-validate - enhanced-resolve@5.22.0: + enhanced-resolve@5.22.1: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 @@ -17823,7 +17850,7 @@ snapshots: has-property-descriptors: 1.0.2 has-proto: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.3 + hasown: 2.0.4 internal-slot: 1.1.0 is-array-buffer: 3.0.5 is-callable: 1.2.7 @@ -17852,7 +17879,7 @@ snapshots: typed-array-buffer: 1.0.3 typed-array-byte-length: 1.0.3 typed-array-byte-offset: 1.0.4 - typed-array-length: 1.0.7 + typed-array-length: 1.0.8 unbox-primitive: 1.1.0 which-typed-array: 1.1.21 @@ -17892,11 +17919,11 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.3 + hasown: 2.0.4 es-shim-unscopables@1.1.0: dependencies: - hasown: 2.0.3 + hasown: 2.0.4 es-to-primitive@1.3.0: dependencies: @@ -18022,7 +18049,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): + eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: @@ -18044,8 +18071,8 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) - hasown: 2.0.3 + eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) + hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 minimatch: 3.1.5 @@ -18073,7 +18100,7 @@ snapshots: damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 eslint: 9.39.4(jiti@2.7.0) - hasown: 2.0.3 + hasown: 2.0.4 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 minimatch: 3.1.5 @@ -18103,7 +18130,7 @@ snapshots: es-iterator-helpers: 1.3.2 eslint: 9.39.4(jiti@2.7.0) estraverse: 5.3.0 - hasown: 2.0.3 + hasown: 2.0.4 jsx-ast-utils: 3.3.5 minimatch: 3.1.5 object.entries: 1.1.9 @@ -18208,15 +18235,15 @@ snapshots: eta@4.6.0: {} - ethers@6.16.0: + ethers@6.17.0: dependencies: - '@adraffy/ens-normalize': 1.10.1 + '@adraffy/ens-normalize': 1.11.1 '@noble/curves': 1.2.0 '@noble/hashes': 1.3.2 '@types/node': 22.7.5 aes-js: 4.0.0-beta.5 tslib: 2.7.0 - ws: 8.17.1 + ws: 8.21.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -18397,7 +18424,7 @@ snapshots: call-bound: 1.0.4 define-properties: 1.2.1 functions-have-names: 1.2.3 - hasown: 2.0.3 + hasown: 2.0.4 is-callable: 1.2.7 functions-have-names@1.2.3: {} @@ -18420,7 +18447,7 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.3 + hasown: 2.0.4 math-intrinsics: 1.1.0 get-nonce@1.0.1: {} @@ -18563,7 +18590,7 @@ snapshots: minimalistic-assert: 1.0.1 optional: true - hasown@2.0.3: + hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -18654,7 +18681,7 @@ snapshots: internal-slot@1.1.0: dependencies: es-errors: 1.3.0 - hasown: 2.0.3 + hasown: 2.0.4 side-channel: 1.1.0 internmap@2.0.3: {} @@ -18700,7 +18727,7 @@ snapshots: is-core-module@2.16.2: dependencies: - hasown: 2.0.3 + hasown: 2.0.4 is-data-view@1.0.2: dependencies: @@ -18763,7 +18790,7 @@ snapshots: call-bound: 1.0.4 gopd: 1.2.0 has-tostringtag: 1.0.2 - hasown: 2.0.3 + hasown: 2.0.4 is-regexp@1.0.0: {} @@ -19368,7 +19395,7 @@ snapshots: listr2: 9.0.5 picomatch: 4.0.4 string-argv: 0.3.2 - tinyexec: 1.2.2 + tinyexec: 1.2.3 yaml: 2.9.0 listr2@9.0.5: @@ -21166,7 +21193,7 @@ snapshots: tinyexec@0.3.2: {} - tinyexec@1.2.2: {} + tinyexec@1.2.3: {} tinyglobby@0.2.16: dependencies: @@ -21280,7 +21307,7 @@ snapshots: is-typed-array: 1.1.15 reflect.getprototypeof: 1.0.10 - typed-array-length@1.0.7: + typed-array-length@1.0.8: dependencies: call-bind: 1.0.9 for-each: 0.3.5 @@ -21703,7 +21730,7 @@ snapshots: acorn-import-phases: 1.0.4(acorn@8.16.0) browserslist: 4.28.2 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.22.0 + enhanced-resolve: 5.22.1 es-module-lexer: 2.1.0 eslint-scope: 5.1.1 events: 3.3.0 @@ -21964,8 +21991,6 @@ snapshots: ws@7.5.11: {} - ws@8.17.1: {} - ws@8.18.2: {} ws@8.20.1: {} @@ -21980,16 +22005,16 @@ snapshots: xtend@4.0.2: {} - y-protocols@1.0.7(yjs@13.6.30): + y-protocols@1.0.7(yjs@13.6.31): dependencies: lib0: 0.2.117 - yjs: 13.6.30 + yjs: 13.6.31 - y-websocket@3.0.0(yjs@13.6.30): + y-websocket@3.0.0(yjs@13.6.31): dependencies: lib0: 0.2.117 - y-protocols: 1.0.7(yjs@13.6.30) - yjs: 13.6.30 + y-protocols: 1.0.7(yjs@13.6.31) + yjs: 13.6.31 y18n@4.0.3: {} @@ -22035,7 +22060,7 @@ snapshots: buffer-crc32: 0.2.13 fd-slicer: 1.1.0 - yjs@13.6.30: + yjs@13.6.31: dependencies: lib0: 0.2.117 diff --git a/src/components/auth/DiscordButton.tsx b/src/components/auth/DiscordButton.tsx new file mode 100644 index 00000000..c14e030b --- /dev/null +++ b/src/components/auth/DiscordButton.tsx @@ -0,0 +1,27 @@ +'use client'; + +import type { MouseEventHandler } from 'react'; + +interface DiscordButtonProps { + onClick?: MouseEventHandler; +} + +export function DiscordButton({ onClick }: DiscordButtonProps) { + return ( + + ); +} + +export default DiscordButton; diff --git a/src/components/social/__tests__/commentSection.additional.test.tsx b/src/components/social/__tests__/commentSection.additional.test.tsx new file mode 100644 index 00000000..f87ec8b1 --- /dev/null +++ b/src/components/social/__tests__/commentSection.additional.test.tsx @@ -0,0 +1,57 @@ +// Additional tests for SocialInteractions component focusing on custom share URL and liked state +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import SocialInteractions from '@/components/social/SocialInteractions'; +import { apiClient } from '@/lib/api'; +import { vi } from 'vitest'; + +vi.mock('@/lib/api', () => ({ + apiClient: { + get: vi.fn(), + post: vi.fn(), + delete: vi.fn(), + }, +})); + +describe('SocialInteractions – Additional edge cases', () => { + const contentId = 'post-1'; + const customUrl = 'https://example.com/custom'; + + beforeEach(() => { + vi.mocked(apiClient.get).mockResolvedValue({ likes: 5, liked: true, comments: [] }); + vi.mocked(apiClient.post).mockResolvedValue({}); + vi.mocked(apiClient.delete).mockResolvedValue({}); + // Reset clipboard mock + Object.defineProperty(window.navigator, 'clipboard', { + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('renders liked state with aria-label "Unlike"', async () => { + render(); + await waitFor(() => expect(screen.getByText('5')).toBeInTheDocument()); + const likeButton = screen.getByLabelText('Unlike'); + expect(likeButton).toBeInTheDocument(); + }); + + it('copies the provided custom contentUrl when share is clicked', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(window.navigator, 'clipboard', { + value: { writeText }, + writable: true, + configurable: true, + }); + + render(); + await waitFor(() => expect(screen.getByLabelText('Copy link')).toBeInTheDocument()); + await userEvent.setup().click(screen.getByLabelLabel('Copy link')); + expect(writeText).toHaveBeenCalledWith(customUrl); + await waitFor(() => expect(screen.getByText('Copied!')).toBeInTheDocument()); + }); +}); diff --git a/src/components/social/__tests__/commentSection.test.tsx b/src/components/social/__tests__/commentSection.test.tsx index f2123cbb..e5738553 100644 --- a/src/components/social/__tests__/commentSection.test.tsx +++ b/src/components/social/__tests__/commentSection.test.tsx @@ -119,6 +119,19 @@ describe('getRelativeTime – extended ranges', () => { it('returns years ago', () => { expect(getRelativeTime(new Date(Date.now() - 400 * 86_400_000))).toBe('1 year ago'); }); + + it('returns "just now" for future dates', () => { + const future = new Date(Date.now() + 3600_000); + expect(getRelativeTime(future)).toBe('just now'); + }); + + it('returns "1 minute ago" at exactly 60 seconds', () => { + expect(getRelativeTime(new Date(Date.now() - 60_000))).toBe('1 minute ago'); + }); + + it('returns "1 hour ago" at exactly 60 minutes', () => { + expect(getRelativeTime(new Date(Date.now() - 3600_000))).toBe('1 hour ago'); + }); }); describe('formatFollowerCount – edge cases', () => { @@ -126,6 +139,7 @@ describe('formatFollowerCount – edge cases', () => { it('handles exactly 1000', () => expect(formatFollowerCount(1000)).toBe('1K')); it('handles exactly 1_000_000', () => expect(formatFollowerCount(1_000_000)).toBe('1M')); it('handles large millions', () => expect(formatFollowerCount(2_500_000)).toBe('2.5M')); + it('handles negative numbers', () => expect(formatFollowerCount(-5)).toBe('-5')); }); describe('groupActivitiesByDate – multi-day grouping', () => { @@ -259,6 +273,32 @@ describe('useSocialInteractions – comment section', () => { await waitFor(() => expect(result.current.comments).toHaveLength(1)); expect(result.current.comments[0].createdAt).toBeInstanceOf(Date); }); + + it('does not append comment when addComment API call fails', async () => { + vi.mocked(apiClient.post).mockRejectedValue(new Error('Network error')); + vi.mocked(apiClient.get).mockResolvedValue({ likes: 0, liked: false, comments: [] }); + const { result } = renderHook(() => useSocialInteractions('post-1')); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(() => result.current.addComment('Will fail').catch(() => {})); + expect(result.current.comments).toHaveLength(0); + }); + + it('re-fetches when contentId changes', async () => { + vi.mocked(apiClient.get) + .mockResolvedValueOnce({ likes: 3, liked: false, comments: [makeComment({ id: 'old' })] }) + .mockResolvedValueOnce({ likes: 7, liked: true, comments: [] }); + + const { result, rerender } = renderHook(({ cid }) => useSocialInteractions(cid), { + initialProps: { cid: 'post-a' }, + }); + await waitFor(() => expect(result.current.likes).toBe(3)); + + rerender({ cid: 'post-b' }); + await waitFor(() => expect(result.current.likes).toBe(7)); + expect(result.current.liked).toBe(true); + expect(result.current.comments).toHaveLength(0); + expect(apiClient.get).toHaveBeenCalledWith('/api/social/interactions/post-b'); + }); }); // ═══════════════════════════════════════════════════════════════════════════════ @@ -270,7 +310,11 @@ describe('SocialInteractions – Comment Section UI', () => { vi.mocked(apiClient.get).mockResolvedValue({ likes: 5, liked: false, comments: [] }); vi.mocked(apiClient.post).mockResolvedValue(makeComment()); vi.mocked(apiClient.delete).mockResolvedValue({}); - stubClipboard(); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + writable: true, + configurable: true, + }); }); afterEach(() => vi.clearAllMocks()); @@ -515,6 +559,130 @@ describe('SocialInteractions – Comment Section UI', () => { expect(screen.getByText(xssName)).toBeInTheDocument(); }); + + // ── Loading state during submission ────────────────────────────────────── + + it('disables the Post button while a comment submission is in progress', async () => { + let resolvePost!: (v: unknown) => void; + vi.mocked(apiClient.post).mockReturnValue(new Promise((res) => (resolvePost = res))); + + const user = userEvent.setup(); + render(); + await user.click(screen.getByLabelText('Toggle comments')); + + const input = screen.getByPlaceholderText('Add a comment…'); + await user.type(input, 'Loading test'); + const postBtn = screen.getByRole('button', { name: /post/i }); + + await user.click(postBtn); + expect(postBtn).toBeDisabled(); + + await act(async () => resolvePost(makeComment({ body: 'Loading test' }))); + await waitFor(() => expect(screen.getByPlaceholderText('Add a comment…')).toHaveValue('')); + }); + + // ── Keyboard submission ────────────────────────────────────────────────── + + it('submits the comment when Enter is pressed in the input', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByLabelText('Toggle comments')); + + const input = screen.getByPlaceholderText('Add a comment…'); + await user.type(input, 'Enter key submit{enter}'); + + await waitFor(() => + expect(apiClient.post).toHaveBeenCalledWith('/api/social/interactions/post-1/comments', { + body: 'Enter key submit', + }), + ); + }); + + // ── Share ──────────────────────────────────────────────────────────────── + + it('copies the provided contentUrl when share is clicked', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + const nav = window.navigator; + Object.defineProperty(nav, 'clipboard', { + value: { writeText }, + writable: true, + configurable: true, + }); + + const user = userEvent.setup(); + render(); + await user.click(screen.getByLabelText('Copy link')); + await waitFor(() => expect(writeText).toHaveBeenCalledWith('https://example.com/custom')); + expect(screen.getByText('Copied!')).toBeInTheDocument(); + }); + + it('handles clipboard write failure gracefully', async () => { + const writeText = vi.fn().mockRejectedValue(new Error('Clipboard denied')); + const nav = window.navigator; + Object.defineProperty(nav, 'clipboard', { + value: { writeText }, + writable: true, + configurable: true, + }); + + const user = userEvent.setup(); + render(); + await user.click(screen.getByLabelText('Copy link')); + + // Should not throw or crash – still shows 'Share' (not 'Copied!') + await waitFor(() => expect(screen.getByText('Share')).toBeInTheDocument()); + }); + + // ── Like button disabled during comment loading ────────────────────────── + + it('disables the Like button while a comment operation is loading', async () => { + let resolvePost!: (v: unknown) => void; + vi.mocked(apiClient.post).mockReturnValue(new Promise((res) => (resolvePost = res))); + + const user = userEvent.setup(); + render(); + await user.click(screen.getByLabelText('Toggle comments')); + + const input = screen.getByPlaceholderText('Add a comment…'); + await user.type(input, 'Hello'); + await user.click(screen.getByRole('button', { name: /post/i })); + + // Like button should be disabled while the comment post is in flight + expect(screen.getByLabelText('Like')).toBeDisabled(); + + await act(async () => resolvePost(makeComment({ body: 'Hello' }))); + await waitFor(() => expect(screen.getByLabelText('Like')).not.toBeDisabled()); + }); + + // ── Comment count updates ──────────────────────────────────────────────── + + it('updates the comment count badge after adding a comment', async () => { + const existing = [makeComment({ id: 'c1' })]; + vi.mocked(apiClient.get).mockResolvedValue({ likes: 0, liked: false, comments: existing }); + vi.mocked(apiClient.post).mockResolvedValue(makeComment({ id: 'c2', body: 'Another' })); + + const user = userEvent.setup(); + render(); + await waitFor(() => expect(screen.getByText('1')).toBeInTheDocument()); + await user.click(screen.getByLabelText('Toggle comments')); + + const input = screen.getByPlaceholderText('Add a comment…'); + await user.type(input, 'Another'); + await user.click(screen.getByRole('button', { name: /post/i })); + + await waitFor(() => expect(screen.getByText('2')).toBeInTheDocument()); + }); + + // ── Zero state ─────────────────────────────────────────────────────────── + + it('renders with zero likes and zero comments initially', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ likes: 0, liked: false, comments: [] }); + render(); + await waitFor(() => expect(screen.getAllByText('0')).toHaveLength(2)); + expect(screen.getByLabelText('Like')).toBeInTheDocument(); + expect(screen.getByLabelText('Toggle comments')).toBeInTheDocument(); + expect(screen.getByLabelText('Copy link')).toBeInTheDocument(); + }); }); // ═══════════════════════════════════════════════════════════════════════════════ diff --git a/src/components/social/__tests__/socialFeatures.test.tsx b/src/components/social/__tests__/socialFeatures.test.tsx index 69f30685..af8e5abd 100644 --- a/src/components/social/__tests__/socialFeatures.test.tsx +++ b/src/components/social/__tests__/socialFeatures.test.tsx @@ -110,6 +110,47 @@ describe('useFollowUser', () => { expect(result.current.isFollowing).toBe(false); expect(apiClient.delete).toHaveBeenCalledWith('/api/social/follow/user-1'); }); + + it('follow() sets loading true then false', async () => { + let resolvePost!: (v: unknown) => void; + vi.mocked(apiClient.post).mockReturnValue(new Promise((res) => (resolvePost = res))); + + const { result } = renderHook(() => useFollowUser('user-1')); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + result.current.follow(); + }); + expect(result.current.loading).toBe(true); + + await act(async () => resolvePost({})); + expect(result.current.loading).toBe(false); + }); + + it('unfollow() sets loading true then false', async () => { + let resolveDelete!: (v: unknown) => void; + vi.mocked(apiClient.delete).mockReturnValue(new Promise((res) => (resolveDelete = res))); + + vi.mocked(apiClient.get).mockResolvedValue({ isFollowing: true }); + const { result } = renderHook(() => useFollowUser('user-1')); + await waitFor(() => expect(result.current.isFollowing).toBe(true)); + + act(() => { + result.current.unfollow(); + }); + expect(result.current.loading).toBe(true); + + await act(async () => resolveDelete({})); + expect(result.current.loading).toBe(false); + }); + + it('follow() handles API error and resets loading', async () => { + vi.mocked(apiClient.post).mockRejectedValue(new Error('Network error')); + const { result } = renderHook(() => useFollowUser('user-1')); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(() => result.current.follow().catch(() => {})); + expect(result.current.loading).toBe(false); + }); }); // ─── useActivityFeed ───────────────────────────────────────────────────────── @@ -145,6 +186,54 @@ describe('useActivityFeed', () => { await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.hasMore).toBe(true); }); + + it('loadMore appends activities and uses cursor', async () => { + const page1 = [mockActivities[0]]; + const page2 = [{ ...mockActivities[1], id: '3', action: 'shared' }]; + + vi.mocked(apiClient.get) + .mockResolvedValueOnce({ data: page1, nextCursor: 'cursor-1' }) + .mockResolvedValueOnce({ data: page2, nextCursor: undefined }); + + const { result } = renderHook(() => useActivityFeed('user-1')); + await waitFor(() => expect(result.current.activities).toHaveLength(1)); + + await act(() => result.current.loadMore()); + expect(result.current.activities).toHaveLength(2); + expect(result.current.activities[1].action).toBe('shared'); + expect(apiClient.get).toHaveBeenLastCalledWith(expect.stringContaining('cursor=cursor-1')); + }); + + it('handles API error in initial load gracefully', async () => { + vi.mocked(apiClient.get).mockRejectedValue(new Error('Server error')); + const { result } = renderHook(() => useActivityFeed('user-1')); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.activities).toHaveLength(0); + expect(result.current.hasMore).toBe(false); + }); + + it('converts createdAt strings to Date objects', async () => { + const raw = { + id: '1', + actorId: 'u1', + actorName: 'Alice', + action: 'liked', + createdAt: '2024-06-15T12:00:00.000Z' as unknown as Date, + }; + vi.mocked(apiClient.get).mockResolvedValue({ data: [raw], nextCursor: undefined }); + const { result } = renderHook(() => useActivityFeed('user-1')); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.activities[0].createdAt).toBeInstanceOf(Date); + }); + + it('does not call loadMore while already loading', async () => { + vi.mocked(apiClient.get).mockReturnValue(new Promise(() => {})); + const { result } = renderHook(() => useActivityFeed('user-1')); + + await act(() => result.current.loadMore()); + // Should not have made a second request while first is pending + expect(apiClient.get).toHaveBeenCalledTimes(1); + }); }); // ─── useSocialInteractions ──────────────────────────────────────────────────── @@ -192,6 +281,61 @@ describe('useSocialInteractions', () => { expect(result.current.comments).toHaveLength(1); expect(result.current.comments[0].body).toBe('Nice!'); }); + + it('toggleLike sends POST when not liked', async () => { + const { result } = renderHook(() => useSocialInteractions('post-1')); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(() => result.current.toggleLike()); + expect(apiClient.post).toHaveBeenCalledWith('/api/social/interactions/post-1/like', {}); + }); + + it('toggleLike sends DELETE when liked', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ likes: 5, liked: true, comments: [] }); + const { result } = renderHook(() => useSocialInteractions('post-1')); + await waitFor(() => expect(result.current.liked).toBe(true)); + await act(() => result.current.toggleLike()); + expect(apiClient.delete).toHaveBeenCalledWith('/api/social/interactions/post-1/like'); + }); + + it('toggleLike sets loading true then false', async () => { + let resolvePost!: (v: unknown) => void; + vi.mocked(apiClient.post).mockReturnValue(new Promise((res) => (resolvePost = res))); + + const { result } = renderHook(() => useSocialInteractions('post-1')); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + result.current.toggleLike(); + }); + expect(result.current.loading).toBe(true); + + await act(async () => resolvePost({})); + expect(result.current.loading).toBe(false); + }); + + it('toggleLike handles API error gracefully and resets loading', async () => { + vi.mocked(apiClient.post).mockRejectedValue(new Error('Server error')); + const { result } = renderHook(() => useSocialInteractions('post-1')); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(() => result.current.toggleLike().catch(() => {})); + expect(result.current.loading).toBe(false); + }); + + it('addComment handles API error gracefully and resets loading', async () => { + vi.mocked(apiClient.post).mockRejectedValue(new Error('Server error')); + const { result } = renderHook(() => useSocialInteractions('post-1')); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(() => result.current.addComment('Will fail').catch(() => {})); + expect(result.current.loading).toBe(false); + }); + + it('handles initial load API error gracefully', async () => { + vi.mocked(apiClient.get).mockRejectedValue(new Error('Server error')); + const { result } = renderHook(() => useSocialInteractions('post-1')); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.likes).toBe(0); + expect(result.current.comments).toHaveLength(0); + }); }); // ─── SocialProfile ──────────────────────────────────────────────────────────── @@ -410,4 +554,71 @@ describe('SocialInteractions', () => { expect.anything(), ); }); + + it('like button is disabled during loading', async () => { + vi.mocked(apiClient.post).mockReturnValue(new Promise(() => {})); + const user_ = userEvent.setup(); + render(); + await waitFor(() => screen.getByText('10')); + const likeBtn = screen.getByLabelText('Like'); + await user_.click(likeBtn); + expect(likeBtn).toBeDisabled(); + }); + + it('like button changes aria-label to Unlike after clicking', async () => { + const user_ = userEvent.setup(); + render(); + await waitFor(() => screen.getByText('10')); + await user_.click(screen.getByLabelText('Like')); + await waitFor(() => expect(screen.getByLabelText('Unlike')).toBeInTheDocument()); + }); + + it('unlike changes aria-label back to Like', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ likes: 10, liked: true, comments: [] }); + const user_ = userEvent.setup(); + render(); + await waitFor(() => screen.getByText('10')); + await user_.click(screen.getByLabelText('Unlike')); + await waitFor(() => expect(screen.getByLabelText('Like')).toBeInTheDocument()); + }); + + it('share button shows Copied! then reverts after timeout', async () => { + vi.useFakeTimers(); + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } }); + + const user_ = userEvent.setup(); + render(); + await user_.click(screen.getByLabelText('Copy link')); + await waitFor(() => expect(screen.getByText('Copied!')).toBeInTheDocument()); + + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(screen.queryByText('Copied!')).not.toBeInTheDocument(); + expect(screen.getByText('Share')).toBeInTheDocument(); + + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('share without contentUrl copies window.location.href', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } }); + const originalHref = window.location.href; + + const user_ = userEvent.setup(); + render(); + await user_.click(screen.getByLabelText('Copy link')); + await waitFor(() => expect(writeText).toHaveBeenCalledWith(originalHref)); + + vi.unstubAllGlobals(); + }); + + it('handles initial load API failure without crashing', async () => { + vi.mocked(apiClient.get).mockRejectedValue(new Error('Server error')); + render(); + await waitFor(() => expect(screen.getByText('Share')).toBeInTheDocument()); + expect(screen.getByText('0')).toBeInTheDocument(); + }); });