diff --git a/frontend/jest.config.js b/frontend/jest.config.js index 3b28ba37..8892a752 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -5,5 +5,5 @@ module.exports = { '^.+\\.tsx?$': 'ts-jest', '^.+\\.jsx?$': 'babel-jest', }, - collectCoverageFrom: ['src/**'], + collectCoverageFrom: ['src/**', '!src/static/lib/**'], }; diff --git a/frontend/tests/utils/hooks/useBulkActions.test.tsx b/frontend/tests/utils/hooks/useBulkActions.test.tsx new file mode 100644 index 00000000..00fca6c5 --- /dev/null +++ b/frontend/tests/utils/hooks/useBulkActions.test.tsx @@ -0,0 +1,749 @@ +import React from 'react'; +import { render, fireEvent, act } from '@testing-library/react'; +import { useBulkActions } from '../../../src/static/js/utils/hooks/useBulkActions'; + +// Mock translateString to return the input for easier assertions +jest.mock('../../../src/static/js/utils/helpers', () => ({ + translateString: (s: string) => s, +})); + +// Component that exposes hook state/handlers to DOM for testing +function HookConsumer() { + const hook = useBulkActions(); + + return ( +
+
{Array.from(hook.selectedMedia).length}
+
{hook.availableMediaIds.length}
+
{String(hook.showConfirmModal)}
+
{hook.confirmMessage}
+
{hook.listKey}
+
{hook.notificationMessage}
+
{String(hook.showNotification)}
+ + {/* @todo: It doesn't used */} + {/*
{hook.notificationType}
*/} + +
{String(hook.showPermissionModal)}
+
{hook.permissionType || ''}
+
{String(hook.showPlaylistModal)}
+
{String(hook.showChangeOwnerModal)}
+
{String(hook.showPublishStateModal)}
+
{String(hook.showCategoryModal)}
+
{String(hook.showTagModal)}
+ +
+ ); +} + +describe('useBulkActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + + document.cookie.split(';').forEach((c) => { + document.cookie = c.replace(/^ +/, '').replace(/=.*/, '=;expires=' + new Date().toUTCString() + ';path=/'); + }); + + global.fetch = jest.fn(); + + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('Utility Functions', () => { + test('getCsrfToken reads csrftoken from cookies', () => { + document.cookie = 'csrftoken=abc123'; + const { getByTestId } = render(); + expect(getByTestId('csrf').textContent).toBe('abc123'); + }); + + test('getCsrfToken returns null when csrftoken is not present', () => { + // No cookie set, should return null + const { getByTestId } = render(); + expect(getByTestId('csrf').textContent).toBe('null'); + }); + + test('getCsrfToken returns null when document.cookie is empty', () => { + // Even if we try to set empty cookie, it should return null if no csrftoken + const { getByTestId } = render(); + expect(getByTestId('csrf').textContent).toBe('null'); + }); + }); + + describe('Selection Management', () => { + test('handleMediaSelection toggles selected media', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-media-select')); + expect(getByTestId('selected-count').textContent).toBe('1'); + + fireEvent.click(getByTestId('btn-handle-media-deselect')); + expect(getByTestId('selected-count').textContent).toBe('0'); + }); + + test('handleItemsUpdate extracts ids correctly from items with different id types', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-items-update')); + expect(getByTestId('available-count').textContent).toBe('3'); + }); + + test('handleSelectAll selects all available items', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-items-update')); + fireEvent.click(getByTestId('btn-select-all')); + expect(getByTestId('selected-count').textContent).toBe('3'); + }); + + test('handleDeselectAll deselects all items', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-items-update')); + fireEvent.click(getByTestId('btn-select-all')); + fireEvent.click(getByTestId('btn-deselect-all')); + expect(getByTestId('selected-count').textContent).toBe('0'); + }); + + test('clearSelection clears all selected media', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-media-select')); + expect(getByTestId('selected-count').textContent).toBe('1'); + + fireEvent.click(getByTestId('btn-clear-selection')); + expect(getByTestId('selected-count').textContent).toBe('0'); + }); + + test('clearSelectionAndRefresh clears selection and increments listKey', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-items-update')); + fireEvent.click(getByTestId('btn-select-all')); + expect(getByTestId('list-key').textContent).toBe('0'); + + fireEvent.click(getByTestId('btn-clear-refresh')); + expect(getByTestId('selected-count').textContent).toBe('0'); + expect(getByTestId('list-key').textContent).toBe('1'); + }); + }); + + describe('Bulk Actions - Modal Opening', () => { + test('handleBulkAction does nothing when no selection', () => { + const { getByTestId } = render(); + fireEvent.click(getByTestId('btn-bulk-delete')); + expect(getByTestId('show-confirm').textContent).toBe('false'); + }); + + test('handleBulkAction opens confirm modal for delete, enable/disable comments and download, copy', () => { + const { getByTestId } = render(); + fireEvent.click(getByTestId('btn-handle-media-select')); + + fireEvent.click(getByTestId('btn-bulk-delete')); + expect(getByTestId('show-confirm').textContent).toBe('true'); + + fireEvent.click(getByTestId('btn-bulk-enable-comments')); + expect(getByTestId('show-confirm').textContent).toBe('true'); + + fireEvent.click(getByTestId('btn-bulk-disable-comments')); + expect(getByTestId('show-confirm').textContent).toBe('true'); + + fireEvent.click(getByTestId('btn-bulk-enable-download')); + expect(getByTestId('show-confirm').textContent).toBe('true'); + + fireEvent.click(getByTestId('btn-bulk-disable-download')); + expect(getByTestId('show-confirm').textContent).toBe('true'); + + fireEvent.click(getByTestId('btn-bulk-copy')); + expect(getByTestId('show-confirm').textContent).toBe('true'); + }); + + test('handleBulkAction opens permission modals with correct types', () => { + const { getByTestId } = render(); + fireEvent.click(getByTestId('btn-handle-media-select')); + + fireEvent.click(getByTestId('btn-bulk-perm-viewer')); + expect(getByTestId('show-permission').textContent).toBe('true'); + expect(getByTestId('permission-type').textContent).toBe('viewer'); + + fireEvent.click(getByTestId('btn-bulk-perm-editor')); + expect(getByTestId('permission-type').textContent).toBe('editor'); + + fireEvent.click(getByTestId('btn-bulk-perm-owner')); + expect(getByTestId('permission-type').textContent).toBe('owner'); + }); + + test('handleBulkAction opens other modals', () => { + const { getByTestId } = render(); + fireEvent.click(getByTestId('btn-handle-media-select')); + + fireEvent.click(getByTestId('btn-bulk-playlist')); + expect(getByTestId('show-playlist').textContent).toBe('true'); + + fireEvent.click(getByTestId('btn-bulk-change-owner')); + expect(getByTestId('show-change-owner').textContent).toBe('true'); + + fireEvent.click(getByTestId('btn-bulk-publish')); + expect(getByTestId('show-publish-state').textContent).toBe('true'); + + fireEvent.click(getByTestId('btn-bulk-category')); + expect(getByTestId('show-category').textContent).toBe('true'); + + fireEvent.click(getByTestId('btn-bulk-tag')); + expect(getByTestId('show-tag').textContent).toBe('true'); + }); + + test('handleBulkAction with unknown action does nothing', () => { + const { getByTestId } = render(); + fireEvent.click(getByTestId('btn-handle-media-select')); + fireEvent.click(getByTestId('btn-bulk-unknown')); + expect(getByTestId('show-confirm').textContent).toBe('false'); + expect(getByTestId('show-permission').textContent).toBe('false'); + }); + }); + + describe('Confirm Modal Handlers', () => { + test('handleConfirmCancel closes confirm modal and resets state', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-media-select')); + fireEvent.click(getByTestId('btn-bulk-delete')); + expect(getByTestId('show-confirm').textContent).toBe('true'); + + fireEvent.click(getByTestId('btn-confirm-cancel')); + expect(getByTestId('show-confirm').textContent).toBe('false'); + expect(getByTestId('confirm-message').textContent).toBe(''); + }); + }); + + describe('Delete Media Execution', () => { + test('executeDeleteMedia success with notification', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) }); + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-media-select')); + fireEvent.click(getByTestId('btn-bulk-delete')); + + await act(async () => { + fireEvent.click(getByTestId('btn-confirm-proceed')); + await Promise.resolve(); + }); + + expect(getByTestId('notification-message').textContent).toContain('The media was deleted successfully'); + expect(getByTestId('show-notification').textContent).toBe('true'); + + act(() => { + jest.advanceTimersByTime(5000); + }); + expect(getByTestId('show-notification').textContent).toBe('false'); + }); + + test('executeDeleteMedia handles response.ok = false', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) }); + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-media-select')); + fireEvent.click(getByTestId('btn-bulk-delete')); + + await act(async () => { + fireEvent.click(getByTestId('btn-confirm-proceed')); + await Promise.resolve(); + }); + + expect(getByTestId('notification-message').textContent).toContain('Failed to delete media'); + }); + + test('executeDeleteMedia handles fetch rejection exception', async () => { + (global.fetch as jest.Mock).mockRejectedValue(new Error('Network error')); + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-media-select')); + fireEvent.click(getByTestId('btn-bulk-delete')); + + await act(async () => { + fireEvent.click(getByTestId('btn-confirm-proceed')); + await Promise.resolve(); + }); + + expect(getByTestId('notification-message').textContent).toContain('Failed to delete media'); + expect(getByTestId('selected-count').textContent).toBe('0'); + }); + }); + + describe('Comments Management Execution', () => { + test('executeEnableComments success', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) }); + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-media-select')); + fireEvent.click(getByTestId('btn-bulk-enable-comments')); + + await act(async () => { + fireEvent.click(getByTestId('btn-confirm-proceed')); + await Promise.resolve(); + }); + + expect(getByTestId('notification-message').textContent).toContain('Successfully Enabled comments'); + }); + + test('executeEnableComments handles response.ok = false', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) }); + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-media-select')); + fireEvent.click(getByTestId('btn-bulk-enable-comments')); + + await act(async () => { + fireEvent.click(getByTestId('btn-confirm-proceed')); + await Promise.resolve(); + }); + + expect(getByTestId('notification-message').textContent).toContain('Failed to enable comments'); + }); + + test('executeEnableComments handles fetch rejection exception', async () => { + (global.fetch as jest.Mock).mockRejectedValue(new Error('Network error')); + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-media-select')); + fireEvent.click(getByTestId('btn-bulk-enable-comments')); + + await act(async () => { + fireEvent.click(getByTestId('btn-confirm-proceed')); + await Promise.resolve(); + }); + + expect(getByTestId('notification-message').textContent).toContain('Failed to enable comments'); + expect(getByTestId('selected-count').textContent).toBe('0'); + }); + + test('executeDisableComments success', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) }); + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-media-select')); + fireEvent.click(getByTestId('btn-bulk-disable-comments')); + + await act(async () => { + fireEvent.click(getByTestId('btn-confirm-proceed')); + await Promise.resolve(); + }); + + expect(getByTestId('notification-message').textContent).toContain('Successfully Disabled comments'); + expect(getByTestId('selected-count').textContent).toBe('0'); + }); + + test('executeDisableComments handles response.ok = false', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) }); + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-media-select')); + fireEvent.click(getByTestId('btn-bulk-disable-comments')); + + await act(async () => { + fireEvent.click(getByTestId('btn-confirm-proceed')); + await Promise.resolve(); + }); + + expect(getByTestId('notification-message').textContent).toContain('Failed to disable comments'); + }); + + test('executeDisableComments handles fetch rejection exception', async () => { + (global.fetch as jest.Mock).mockRejectedValue(new Error('Network error')); + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-media-select')); + fireEvent.click(getByTestId('btn-bulk-disable-comments')); + + await act(async () => { + fireEvent.click(getByTestId('btn-confirm-proceed')); + await Promise.resolve(); + }); + + expect(getByTestId('notification-message').textContent).toContain('Failed to disable comments'); + expect(getByTestId('selected-count').textContent).toBe('0'); + }); + }); + + describe('Download Management Execution', () => { + test('executeEnableDownload success', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) }); + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-media-select')); + fireEvent.click(getByTestId('btn-bulk-enable-download')); + + await act(async () => { + fireEvent.click(getByTestId('btn-confirm-proceed')); + await Promise.resolve(); + }); + + expect(getByTestId('notification-message').textContent).toContain('Successfully Enabled Download'); + }); + + test('executeEnableDownload handles response.ok = false', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) }); + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-media-select')); + fireEvent.click(getByTestId('btn-bulk-enable-download')); + + await act(async () => { + fireEvent.click(getByTestId('btn-confirm-proceed')); + await Promise.resolve(); + }); + + expect(getByTestId('notification-message').textContent).toContain('Failed to enable download'); + }); + + test('executeEnableDownload handles fetch rejection exception', async () => { + (global.fetch as jest.Mock).mockRejectedValue(new Error('Network error')); + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-media-select')); + fireEvent.click(getByTestId('btn-bulk-enable-download')); + + await act(async () => { + fireEvent.click(getByTestId('btn-confirm-proceed')); + await Promise.resolve(); + }); + + expect(getByTestId('notification-message').textContent).toContain('Failed to enable download'); + expect(getByTestId('selected-count').textContent).toBe('0'); + }); + + test('executeDisableDownload success', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) }); + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-media-select')); + fireEvent.click(getByTestId('btn-bulk-disable-download')); + + await act(async () => { + fireEvent.click(getByTestId('btn-confirm-proceed')); + await Promise.resolve(); + }); + + expect(getByTestId('notification-message').textContent).toContain('Successfully Disabled Download'); + expect(getByTestId('selected-count').textContent).toBe('0'); + }); + + test('executeDisableDownload handles response.ok = false', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) }); + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-media-select')); + fireEvent.click(getByTestId('btn-bulk-disable-download')); + + await act(async () => { + fireEvent.click(getByTestId('btn-confirm-proceed')); + await Promise.resolve(); + }); + + expect(getByTestId('notification-message').textContent).toContain('Failed to disable download'); + }); + + test('executeDisableDownload handles fetch rejection exception', async () => { + (global.fetch as jest.Mock).mockRejectedValue(new Error('Network error')); + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-media-select')); + fireEvent.click(getByTestId('btn-bulk-disable-download')); + + await act(async () => { + fireEvent.click(getByTestId('btn-confirm-proceed')); + await Promise.resolve(); + }); + + expect(getByTestId('notification-message').textContent).toContain('Failed to disable download'); + expect(getByTestId('selected-count').textContent).toBe('0'); + }); + }); + + describe('Copy Media Execution', () => { + test('executeCopyMedia success', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) }); + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-media-select')); + fireEvent.click(getByTestId('btn-bulk-copy')); + + await act(async () => { + fireEvent.click(getByTestId('btn-confirm-proceed')); + await Promise.resolve(); + }); + + expect(getByTestId('notification-message').textContent).toContain('Successfully Copied'); + }); + + test('executeCopyMedia handles response.ok = false', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) }); + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-media-select')); + fireEvent.click(getByTestId('btn-bulk-copy')); + + await act(async () => { + fireEvent.click(getByTestId('btn-confirm-proceed')); + await Promise.resolve(); + }); + + expect(getByTestId('notification-message').textContent).toContain('Failed to copy media'); + }); + + test('executeCopyMedia handles fetch rejection exception', async () => { + (global.fetch as jest.Mock).mockRejectedValue(new Error('Network error')); + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-media-select')); + fireEvent.click(getByTestId('btn-bulk-copy')); + + await act(async () => { + fireEvent.click(getByTestId('btn-confirm-proceed')); + await Promise.resolve(); + }); + + expect(getByTestId('notification-message').textContent).toContain('Failed to copy media'); + expect(getByTestId('selected-count').textContent).toBe('0'); + }); + }); + + describe('Permission Modal Handlers', () => { + test('handlePermissionModalCancel closes permission modal', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-media-select')); + fireEvent.click(getByTestId('btn-bulk-perm-viewer')); + expect(getByTestId('show-permission').textContent).toBe('true'); + + fireEvent.click(getByTestId('btn-perm-cancel')); + expect(getByTestId('show-permission').textContent).toBe('false'); + expect(getByTestId('permission-type').textContent).toBe(''); + }); + + test('handlePermissionModalSuccess shows notification and closes modal', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-perm-success')); + expect(getByTestId('notification-message').textContent).toBe('perm ok'); + expect(getByTestId('show-permission').textContent).toBe('false'); + }); + + test('handlePermissionModalError shows error notification and closes modal', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-perm-error')); + expect(getByTestId('notification-message').textContent).toBe('perm err'); + expect(getByTestId('show-permission').textContent).toBe('false'); + }); + }); + + describe('Playlist Modal Handlers', () => { + test('handlePlaylistModalCancel closes playlist modal', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-media-select')); + fireEvent.click(getByTestId('btn-bulk-playlist')); + expect(getByTestId('show-playlist').textContent).toBe('true'); + + fireEvent.click(getByTestId('btn-playlist-cancel')); + expect(getByTestId('show-playlist').textContent).toBe('false'); + }); + + test('handlePlaylistModalSuccess shows notification and closes modal', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-playlist-success')); + expect(getByTestId('notification-message').textContent).toBe('pl ok'); + expect(getByTestId('show-playlist').textContent).toBe('false'); + }); + + test('handlePlaylistModalError shows error notification and closes modal', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-playlist-error')); + expect(getByTestId('notification-message').textContent).toBe('pl err'); + expect(getByTestId('show-playlist').textContent).toBe('false'); + }); + }); + + describe('Change Owner Modal Handlers', () => { + test('handleChangeOwnerModalCancel closes change owner modal', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-media-select')); + fireEvent.click(getByTestId('btn-bulk-change-owner')); + expect(getByTestId('show-change-owner').textContent).toBe('true'); + + fireEvent.click(getByTestId('btn-change-owner-cancel')); + expect(getByTestId('show-change-owner').textContent).toBe('false'); + }); + + test('handleChangeOwnerModalSuccess shows notification and closes modal', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-change-owner-success')); + expect(getByTestId('notification-message').textContent).toBe('owner ok'); + expect(getByTestId('show-change-owner').textContent).toBe('false'); + }); + + test('handleChangeOwnerModalError shows error notification and closes modal', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-change-owner-error')); + expect(getByTestId('notification-message').textContent).toBe('owner err'); + expect(getByTestId('show-change-owner').textContent).toBe('false'); + }); + }); + + describe('Publish State Modal Handlers', () => { + test('handlePublishStateModalCancel closes publish state modal', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-media-select')); + fireEvent.click(getByTestId('btn-bulk-publish')); + expect(getByTestId('show-publish-state').textContent).toBe('true'); + + fireEvent.click(getByTestId('btn-publish-cancel')); + expect(getByTestId('show-publish-state').textContent).toBe('false'); + }); + + test('handlePublishStateModalSuccess shows notification and closes modal', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-publish-success')); + expect(getByTestId('notification-message').textContent).toBe('pub ok'); + expect(getByTestId('show-publish-state').textContent).toBe('false'); + }); + + test('handlePublishStateModalError shows error notification and closes modal', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-publish-error')); + expect(getByTestId('notification-message').textContent).toBe('pub err'); + expect(getByTestId('show-publish-state').textContent).toBe('false'); + }); + }); + + describe('Category Modal Handlers', () => { + test('handleCategoryModalCancel closes category modal', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-media-select')); + fireEvent.click(getByTestId('btn-bulk-category')); + expect(getByTestId('show-category').textContent).toBe('true'); + + fireEvent.click(getByTestId('btn-category-cancel')); + expect(getByTestId('show-category').textContent).toBe('false'); + }); + + test('handleCategoryModalSuccess shows notification and closes modal', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-category-success')); + expect(getByTestId('notification-message').textContent).toBe('cat ok'); + expect(getByTestId('show-category').textContent).toBe('false'); + }); + + test('handleCategoryModalError shows error notification and closes modal', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-category-error')); + expect(getByTestId('notification-message').textContent).toBe('cat err'); + expect(getByTestId('show-category').textContent).toBe('false'); + }); + }); + + describe('Tag Modal Handlers', () => { + test('handleTagModalCancel closes tag modal', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-handle-media-select')); + fireEvent.click(getByTestId('btn-bulk-tag')); + expect(getByTestId('show-tag').textContent).toBe('true'); + + fireEvent.click(getByTestId('btn-tag-cancel')); + expect(getByTestId('show-tag').textContent).toBe('false'); + }); + + test('handleTagModalSuccess shows notification and closes modal', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-tag-success')); + expect(getByTestId('notification-message').textContent).toBe('tag ok'); + expect(getByTestId('show-tag').textContent).toBe('false'); + }); + + test('handleTagModalError shows error notification and closes modal', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('btn-tag-error')); + expect(getByTestId('notification-message').textContent).toBe('tag err'); + expect(getByTestId('show-tag').textContent).toBe('false'); + }); + }); +}); diff --git a/frontend/tests/utils/hooks/useItem.test.tsx b/frontend/tests/utils/hooks/useItem.test.tsx new file mode 100644 index 00000000..d75514b4 --- /dev/null +++ b/frontend/tests/utils/hooks/useItem.test.tsx @@ -0,0 +1,380 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { useItem } from '../../../src/static/js/utils/hooks/useItem'; + +// Mock the item components +jest.mock('../../../src/static/js/components/list-item/includes/items', () => ({ + ItemDescription: ({ description }: { description: string }) => ( +
{description}
+ ), + ItemMain: ({ children }: { children: React.ReactNode }) =>
{children}
, + ItemMainInLink: ({ children, link, title }: { children: React.ReactNode; link: string; title: string }) => ( +
+ {children} +
+ ), + ItemTitle: ({ title, ariaLabel }: { title: string; ariaLabel: string }) => ( +

+ {title} +

+ ), + ItemTitleLink: ({ title, link, ariaLabel }: { title: string; link: string; ariaLabel: string }) => ( +

+ {title} +

+ ), +})); + +// Mock PageStore +jest.mock('../../../src/static/js/utils/stores/PageStore.js', () => ({ + __esModule: true, + default: { + get: (key: string) => (key === 'config-site' ? { url: 'https://example.com' } : null), + }, +})); + +// HookConsumer component to test the hook +function HookConsumer(props: any) { + const { titleComponent, descriptionComponent, thumbnailUrl, UnderThumbWrapper } = useItem(props); + + return ( +
+
{titleComponent()}
+
{descriptionComponent()}
+
{thumbnailUrl || 'null'}
+
{(UnderThumbWrapper as any).name}
+
+
Wrapper content
+
+
+ ); +} + +// Wrapper consumer to test wrapper selection +function WrapperTest(props: any) { + const { UnderThumbWrapper } = useItem(props); + + return ( + + Content + + ); +} + +describe('utils/hooks', () => { + describe('useItem', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('titleComponent Rendering', () => { + test('Renders ItemTitle when singleLinkContent is true', () => { + const { getByTestId } = render( + + ); + + expect(getByTestId('title').querySelector('[data-testid="item-title"]')).toBeTruthy(); + expect(getByTestId('title').querySelector('[data-testid="item-title-link"]')).toBeFalsy(); + }); + + test('Renders ItemTitleLink when singleLinkContent is false', () => { + const { getByTestId } = render( + + ); + + expect(getByTestId('title').querySelector('[data-testid="item-title"]')).toBeFalsy(); + expect(getByTestId('title').querySelector('[data-testid="item-title-link"]')).toBeTruthy(); + }); + + test('Renders with default link when singleLinkContent is not provided', () => { + const { getByTestId } = render( + + ); + + // Default is false for singleLinkContent + expect(getByTestId('title').querySelector('[data-testid="item-title-link"]')).toBeTruthy(); + }); + }); + + describe('descriptionComponent Rendering', () => { + test('Renders single ItemDescription when hasMediaViewer is false', () => { + const { getByTestId, queryAllByTestId } = render( + + ); + + const descriptions = queryAllByTestId('item-description'); + expect(descriptions.length).toBe(1); + expect(descriptions[0].textContent).toBe('My Description'); + }); + + test('Renders single ItemDescription when hasMediaViewerDescr is false', () => { + const { getByTestId, queryAllByTestId } = render( + + ); + + const descriptions = queryAllByTestId('item-description'); + expect(descriptions.length).toBe(1); + expect(descriptions[0].textContent).toBe('My Description'); + }); + + test('Renders two ItemDescriptions when hasMediaViewer and hasMediaViewerDescr are both true', () => { + const { queryAllByTestId } = render( + + ); + + const descriptions = queryAllByTestId('item-description'); + expect(descriptions.length).toBe(2); + expect(descriptions[0].textContent).toBe('Meta Description'); + expect(descriptions[1].textContent).toBe('Main Description'); + }); + + test('Trims description text', () => { + const { queryAllByTestId } = render( + + ); + + expect(queryAllByTestId('item-description')[0].textContent).toBe('Description with spaces'); + }); + + test('Trims meta_description text', () => { + const { queryAllByTestId } = render( + + ); + + expect(queryAllByTestId('item-description')[0].textContent).toBe('Meta with spaces'); + }); + }); + + describe('thumbnailUrl', () => { + test('Returns null when thumbnail is empty string', () => { + const { getByTestId } = render( + + ); + + expect(getByTestId('thumbnail-url').textContent).toBe('null'); + }); + + test('Returns formatted URL when thumbnail has value', () => { + const { getByTestId } = render( + + ); + + expect(getByTestId('thumbnail-url').textContent).toBe('https://example.com/media/thumbnail.jpg'); + }); + + test('Handles absolute URLs as thumbnails', () => { + const { getByTestId } = render( + + ); + + // formatInnerLink should preserve absolute URLs + expect(getByTestId('thumbnail-url').textContent).toBe('https://cdn.example.com/image.jpg'); + }); + }); + + describe('UnderThumbWrapper', () => { + test('Uses ItemMainInLink when singleLinkContent is true', () => { + const { getByTestId } = render( + + ); + + // When singleLinkContent is true, UnderThumbWrapper should be ItemMainInLink + expect(getByTestId('item-main-in-link')).toBeTruthy(); + expect(getByTestId('item-main-in-link').getAttribute('data-link')).toBe('https://example.com'); + expect(getByTestId('item-main-in-link').getAttribute('data-title')).toBe('Test Title'); + }); + + test('Uses ItemMain when singleLinkContent is false', () => { + const { getByTestId } = render( + + ); + + // When singleLinkContent is false, UnderThumbWrapper should be ItemMain + expect(getByTestId('item-main')).toBeTruthy(); + }); + + test('Uses ItemMain by default when singleLinkContent is not provided', () => { + const { getByTestId } = render( + + ); + + // Default is singleLinkContent=false, so ItemMain + expect(getByTestId('item-main')).toBeTruthy(); + }); + }); + + describe('onMount callback', () => { + test('Calls onMount callback when component mounts', () => { + const onMountCallback = jest.fn(); + + render( + + ); + + expect(onMountCallback).toHaveBeenCalledTimes(1); + }); + + test('Calls onMount only once on initial mount', () => { + const onMountCallback = jest.fn(); + + const { rerender } = render( + + ); + + expect(onMountCallback).toHaveBeenCalledTimes(1); + + rerender( + + ); + + // Should still be called only once (useEffect with empty dependency array) + expect(onMountCallback).toHaveBeenCalledTimes(1); + }); + }); + + describe('Integration tests', () => { + test('Complete rendering with all props', () => { + const onMount = jest.fn(); + const { getByTestId, queryAllByTestId } = render( + + ); + + const descriptions = queryAllByTestId('item-description'); + expect(descriptions.length).toBe(2); + expect(onMount).toHaveBeenCalledTimes(1); + expect(getByTestId('thumbnail-url').textContent).toBe('https://example.com/img/thumb.jpg'); + }); + + test('Minimal props required', () => { + const { getByTestId } = render( + + ); + + expect(getByTestId('title')).toBeTruthy(); + expect(getByTestId('description')).toBeTruthy(); + expect(getByTestId('thumbnail-url').textContent).toBe('null'); + }); + + test('Renders with special characters in title and description', () => { + const { queryAllByTestId } = render( + + ); + + const descriptions = queryAllByTestId('item-description'); + expect(descriptions[0].textContent).toContain('Description with'); + }); + }); + }); +}); diff --git a/frontend/tests/utils/hooks/useItemList.test.tsx b/frontend/tests/utils/hooks/useItemList.test.tsx new file mode 100644 index 00000000..e895b962 --- /dev/null +++ b/frontend/tests/utils/hooks/useItemList.test.tsx @@ -0,0 +1,124 @@ +import React, { createRef } from 'react'; +import { render } from '@testing-library/react'; + +// Stub style imports used by the hook so Jest doesn't try to parse SCSS +jest.mock('../../../src/static/js/components/item-list/ItemList.scss', () => ({}), { virtual: true }); + +jest.mock('../../../src/static/js/components/item-list/includes/itemLists/initItemsList', () => ({ + __esModule: true, + default: jest.fn((_lists: any[]) => [{ appendItems: jest.fn() }]), +})); + +import initItemsList from '../../../src/static/js/components/item-list/includes/itemLists/initItemsList'; +import { useItemList } from '../../../src/static/js/utils/hooks/useItemList'; + +function HookConsumer(props: any) { + const listRef = createRef(); + const [items, countedItems, listHandler, setListHandler, onItemsLoad, onItemsCount, addListItems] = useItemList( + props, + listRef + ) as any[]; + + return ( +
+
+ {(items as any[]).map((_, idx) => ( +
+ ))} +
+
{String(countedItems)}
+
{items.length}
+
+ ); +} + +describe('utils/hooks', () => { + describe('useItemList', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('Initial state: empty items and not counted', () => { + const { getByTestId } = render(); + expect(getByTestId('counted').textContent).toBe('false'); + expect(getByTestId('len').textContent).toBe('0'); + expect(getByTestId('has-handler').textContent).toBe('no'); + }); + + test('onItemsLoad updates items and renders item nodes', () => { + const { getByTestId, getByTestId: $ } = render(); + (getByTestId('load-call') as HTMLButtonElement).click(); + expect(getByTestId('len').textContent).toBe('2'); + expect($('itm-0')).toBeTruthy(); + expect($('itm-1')).toBeTruthy(); + }); + + test('onItemsCount marks countedItems true and triggers callback if provided', () => { + const cb = jest.fn(); + const { getByTestId } = render(); + (getByTestId('count-call') as HTMLButtonElement).click(); + expect(getByTestId('counted').textContent).toBe('true'); + expect(cb).toHaveBeenCalledWith(5); + }); + + test('addListItems initializes itemsListInstance and appends only new items', () => { + const mockInit = initItemsList as jest.Mock; + + const { getByTestId, rerender } = render(); + + const itemsLen = getByTestId('len') as HTMLDivElement; + const addBtn = getByTestId('add-call') as HTMLButtonElement; + const loadBtn = getByTestId('load-call') as HTMLButtonElement; + + expect(itemsLen.textContent).toBe('0'); + loadBtn.click(); + expect(itemsLen.textContent).toBe('2'); + + expect(mockInit).toHaveBeenCalledTimes(0); + addBtn.click(); + expect(mockInit).toHaveBeenCalledTimes(1); + + expect(mockInit.mock.results[0].value[0].appendItems).toHaveBeenCalledTimes(2); + + loadBtn.click(); + expect(itemsLen.textContent).toBe('2'); + + addBtn.click(); + expect(mockInit).toHaveBeenCalledTimes(2); + expect(mockInit.mock.results[1].value[0].appendItems).toHaveBeenCalledTimes(2); + + rerender(); + + addBtn.click(); + expect(mockInit).toHaveBeenCalledTimes(3); + expect(mockInit.mock.results[2].value[0].appendItems).toHaveBeenCalledTimes(2); + }); + + test('addListItems does nothing when there are no .item elements in the ref', () => { + // Render, do not call onItemsLoad, then call addListItems + const mockInit = initItemsList as jest.Mock; + const { getByTestId } = render(); + (getByTestId('add-call') as HTMLButtonElement).click(); + expect(mockInit).not.toHaveBeenCalled(); + }); + + test('itemsLoadCallback is invoked when items change', () => { + const itemsLoadCallback = jest.fn(); + const { getByTestId } = render(); + (getByTestId('load-call') as HTMLButtonElement).click(); + expect(itemsLoadCallback).toHaveBeenCalledTimes(1); + }); + + test('setListHandler updates listHandler', () => { + const { getByTestId } = render(); + expect(getByTestId('has-handler').textContent).toBe('no'); + (getByTestId('set-handler') as HTMLButtonElement).click(); + expect(getByTestId('has-handler').textContent).toBe('yes'); + }); + }); +}); diff --git a/frontend/tests/utils/hooks/useItemListInlineSlider.test.tsx b/frontend/tests/utils/hooks/useItemListInlineSlider.test.tsx new file mode 100644 index 00000000..12dff94c --- /dev/null +++ b/frontend/tests/utils/hooks/useItemListInlineSlider.test.tsx @@ -0,0 +1,346 @@ +import React from 'react'; +import { render, fireEvent, act } from '@testing-library/react'; + +jest.mock('../../../src/static/js/utils/settings/config', () => ({ + config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig), +})); + +jest.mock('../../../src/static/js/utils/classes/', () => ({ + BrowserCache: jest.fn().mockImplementation(() => ({ + get: jest.fn(), + set: jest.fn(), + })), +})); + +jest.mock('../../../src/static/js/utils/helpers/', () => ({ + addClassname: jest.fn(), + removeClassname: jest.fn(), +})); + +let mockListHandler: any; +let mockInlineSliderInstance: any; +let addListItemsSpy = jest.fn(); + +jest.mock('../../../src/static/js/utils/hooks/useItemList', () => ({ + useItemList: (props: any, _ref: any) => { + mockListHandler = { + loadItems: jest.fn(), + totalPages: jest.fn().mockReturnValue(props.__totalPages ?? 1), + loadedAllItems: jest.fn().mockReturnValue(Boolean(props.__loadedAll ?? true)), + }; + return [ + props.__items ?? [], // items + props.__countedItems ?? 0, // countedItems + mockListHandler, // listHandler + jest.fn(), // setListHandler + jest.fn(), // onItemsLoad + jest.fn(), // onItemsCount + addListItemsSpy, // addListItems + ]; + }, +})); + +jest.mock('../../../src/static/js/components/item-list/includes/itemLists/ItemsInlineSlider', () => + jest.fn().mockImplementation(() => { + mockInlineSliderInstance = { + updateDataStateOnResize: jest.fn(), + updateDataState: jest.fn(), + scrollToCurrentSlide: jest.fn(), + nextSlide: jest.fn(), + previousSlide: jest.fn(), + hasNextSlide: jest.fn().mockReturnValue(true), + hasPreviousSlide: jest.fn().mockReturnValue(true), + loadItemsToFit: jest.fn().mockReturnValue(false), + loadMoreItems: jest.fn().mockReturnValue(false), + itemsFit: jest.fn().mockReturnValue(3), + }; + return mockInlineSliderInstance; + }) +); + +jest.mock('../../../src/static/js/components/_shared', () => ({ + CircleIconButton: ({ children, onClick }: any) => ( + + ), +})); + +import { useItemListInlineSlider } from '../../../src/static/js/utils/hooks/useItemListInlineSlider'; + +function HookConsumer(props: any) { + const tuple = useItemListInlineSlider(props); + const [ + _items, + _countedItems, + _listHandler, + classname, + _setListHandler, + _onItemsCount, + _onItemsLoad, + _winResizeListener, + _sidebarVisibilityChangeListener, + itemsListWrapperRef, + _itemsListRef, + renderBeforeListWrap, + renderAfterListWrap, + ] = tuple as any; + + return ( +
+
{classname.list}
+
{classname.listOuter}
+
{renderBeforeListWrap()}
+
{renderAfterListWrap()}
+
+ ); +} + +describe('utils/hooks', () => { + describe('useItemListInlineSlider', () => { + beforeEach(() => { + addListItemsSpy = jest.fn(); + mockInlineSliderInstance = null; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Returns correct tuple of values from hook', () => { + const TestComponent = (props: any) => { + const tuple = useItemListInlineSlider(props); + return ( +
+
{tuple.length}
+
{tuple[0] ? 'yes' : 'no'}
+
{tuple[3] ? 'yes' : 'no'}
+
{typeof tuple[7] === 'function' ? 'yes' : 'no'}
+
+ ); + }; + + const { getByTestId } = render(); + + expect(getByTestId('tuple-length').textContent).toBe('13'); + expect(getByTestId('has-classname').textContent).toBe('yes'); + expect(getByTestId('has-listeners').textContent).toBe('yes'); + }); + + test('Computes classname.list and classname.listOuter with optional className prop', () => { + const { getByTestId, rerender } = render(); + + expect(getByTestId('class-outer').textContent).toBe('items-list-outer list-inline list-slider extra '); + expect(getByTestId('class-list').textContent).toBe('items-list'); + + rerender(); + + expect(getByTestId('class-outer').textContent).toBe('items-list-outer list-inline list-slider'); + expect(getByTestId('class-list').textContent).toBe('items-list'); + }); + + test('Invokes addListItems when items change', () => { + const { rerender } = render(); + expect(addListItemsSpy).toHaveBeenCalledTimes(1); + rerender(); + expect(addListItemsSpy).toHaveBeenCalledTimes(2); + }); + + test('nextSlide loads more items when loadMoreItems returns true and not all items loaded', () => { + const { getByTestId } = render(); + + mockInlineSliderInstance.loadMoreItems.mockReturnValue(true); + + const renderAfter = getByTestId('render-after'); + const nextButton = renderAfter.querySelector('button[data-testid="circle-icon-button"]'); + + expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0); + expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0); + expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0); + expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0); + expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(1); + expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(1); + expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1); + + fireEvent.click(nextButton!); + + expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0); + expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(1); + expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(1); + expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0); + expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(2); + expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(2); + expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1); + }); + + test('nextSlide does not load items when all items already loaded', () => { + const { getByTestId } = render(); + + mockInlineSliderInstance.loadMoreItems.mockReturnValue(false); + + const renderAfter = getByTestId('render-after'); + const nextButton = renderAfter.querySelector('button[data-testid="circle-icon-button"]'); + + expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0); + expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0); + expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0); + expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0); + expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(1); + expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(1); + expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1); + + fireEvent.click(nextButton!); + + expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0); + expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0); + expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(1); + expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0); + expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(2); + expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(2); + expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(2); + }); + + test('prevSlide calls inlineSlider.previousSlide and updates button view', () => { + const { getByTestId } = render(); + + mockInlineSliderInstance.loadMoreItems.mockReturnValue(true); + + const renderBefore = getByTestId('render-before'); + const prevButton = renderBefore.querySelector('button[data-testid="circle-icon-button"]'); + + expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0); + expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0); + expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0); + expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0); + expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(1); + expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(1); + expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1); + + fireEvent.click(prevButton!); + + expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0); + expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0); + expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0); + expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(1); + expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(2); + expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(2); + expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(2); + }); + + test('prevSlide always scrolls to current slide regardless of item load state', () => { + const { getByTestId } = render(); + + mockInlineSliderInstance.loadMoreItems.mockReturnValue(false); + + const renderBefore = getByTestId('render-before'); + const prevButton = renderBefore.querySelector('button[data-testid="circle-icon-button"]'); + + expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0); + expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0); + expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0); + expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0); + expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(1); + expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(1); + expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1); + + fireEvent.click(prevButton!); + + expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0); + expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0); + expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0); + expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(1); + expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(2); + expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(2); + expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(2); + }); + + test('Button state updates based on hasNextSlide and hasPreviousSlide', () => { + const { getByTestId, rerender } = render(); + + const renderBefore = getByTestId('render-before'); + const renderAfter = getByTestId('render-after'); + + // Initially should show buttons (default mock returns true) + expect(renderBefore.querySelector('button')).toBeTruthy(); + expect(renderAfter.querySelector('button')).toBeTruthy(); + + // Now set hasNextSlide and hasPreviousSlide to false + mockInlineSliderInstance.hasNextSlide.mockReturnValue(false); + mockInlineSliderInstance.hasPreviousSlide.mockReturnValue(false); + + // Trigger re-render by changing items + rerender(); + + // The next and previous buttons should not be rendered now + const newRenderAfter = getByTestId('render-after'); + const newRenderBefore = getByTestId('render-before'); + expect(newRenderAfter.querySelector('button')).toBeNull(); + expect(newRenderBefore.querySelector('button')).toBeNull(); + }); + + test('winResizeListener and sidebarVisibilityChangeListener are returned as callable functions', () => { + const TestComponentWithListeners = (props: any) => { + const tuple = useItemListInlineSlider(props); + + const winResizeListener = tuple[7]; // winResizeListener + const sidebarListener = tuple[8]; // sidebarVisibilityChangeListener + const wrapperRef = tuple[9]; // itemsListWrapperRef + + return ( +
+ + +
+ ); + }; + + const { getByTestId } = render(); + + // Should not throw when called + const resizeButton = getByTestId('trigger-resize'); + const sidebarButton = getByTestId('trigger-sidebar'); + + expect(() => fireEvent.click(resizeButton)).not.toThrow(); + expect(() => fireEvent.click(sidebarButton)).not.toThrow(); + }); + + test('winResizeListener updates resizeDate state triggering resize effect', () => { + const TestComponent = (props: any) => { + const tuple = useItemListInlineSlider(props) as any; + const winResizeListener = tuple[7]; + const wrapperRef = tuple[9]; + + return ( +
+ +
+ ); + }; + + const { getByTestId } = render(); + + expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1); + expect(mockInlineSliderInstance.updateDataStateOnResize).toHaveBeenCalledTimes(0); + + jest.useFakeTimers(); + + fireEvent.click(getByTestId('trigger-resize')); + + expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(2); + expect(mockInlineSliderInstance.updateDataStateOnResize).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(200); + + expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(3); + expect(mockInlineSliderInstance.updateDataStateOnResize).toHaveBeenCalledTimes(2); + + jest.useRealTimers(); + }); + }); +}); diff --git a/frontend/tests/utils/hooks/useItemListLazyLoad.test.tsx b/frontend/tests/utils/hooks/useItemListLazyLoad.test.tsx new file mode 100644 index 00000000..e776b4b5 --- /dev/null +++ b/frontend/tests/utils/hooks/useItemListLazyLoad.test.tsx @@ -0,0 +1,190 @@ +import React from 'react'; +import { render, fireEvent, waitFor } from '@testing-library/react'; + +jest.mock('../../../src/static/js/utils/settings/config', () => ({ + config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig), +})); + +jest.mock('../../../src/static/js/utils/classes/', () => ({ + BrowserCache: jest.fn().mockImplementation(() => ({ + get: jest.fn(), + set: jest.fn(), + })), +})); + +let mockListHandler: any; +let addListItemsSpy = jest.fn(); +const mockRemoveListener = jest.fn(); + +jest.mock('../../../src/static/js/utils/hooks/useItemList', () => ({ + useItemList: (props: any, _ref: any) => { + mockListHandler = { + loadItems: jest.fn(), + totalPages: jest.fn().mockReturnValue(props.__totalPages ?? 1), + loadedAllItems: jest.fn().mockReturnValue(Boolean(props.__loadedAll ?? true)), + }; + return [ + props.__items ?? [], // items + props.__countedItems ?? 0, // countedItems + mockListHandler, // listHandler + jest.fn(), // setListHandler + jest.fn(), // onItemsLoad + jest.fn(), // onItemsCount + addListItemsSpy, // addListItems + ]; + }, +})); + +jest.mock('../../../src/static/js/utils/stores/', () => ({ + PageStore: { + removeListener: mockRemoveListener, + }, +})); + +import { useItemListLazyLoad } from '../../../src/static/js/utils/hooks/useItemListLazyLoad'; + +function HookConsumer(props: any) { + const tuple = useItemListLazyLoad(props); + + const [ + _items, + _countedItems, + _listHandler, + _setListHandler, + classname, + _onItemsCount, + _onItemsLoad, + _onWindowScroll, + _onDocumentVisibilityChange, + _itemsListWrapperRef, + _itemsListRef, + renderBeforeListWrap, + renderAfterListWrap, + ] = tuple as any; + + return ( +
+
{classname.list}
+
{classname.listOuter}
+
{renderBeforeListWrap()}
+
{renderAfterListWrap()}
+
+ ); +} + +function HookConsumerWithRefs(props: any) { + const tuple = useItemListLazyLoad(props); + const [ + _items, + _countedItems, + _listHandler, + _setListHandler, + classname, + _onItemsCount, + _onItemsLoad, + onWindowScroll, + onDocumentVisibilityChange, + itemsListWrapperRef, + itemsListRef, + renderBeforeListWrap, + renderAfterListWrap, + ] = tuple as any; + + return ( +
+
{classname.list}
+
{classname.listOuter}
+
+
{renderBeforeListWrap()}
+
{renderAfterListWrap()}
+ + +
+ ); +} + +describe('utils/hooks', () => { + describe('useItemListLazyLoad', () => { + beforeEach(() => { + addListItemsSpy = jest.fn(); + mockRemoveListener.mockClear(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Computes classname.list and classname.listOuter with optional className prop', () => { + const { getByTestId, rerender } = render(); + expect(getByTestId('class-outer').textContent).toBe('items-list-outer extra'); + expect(getByTestId('class-list').textContent).toBe('items-list'); + rerender(); + expect(getByTestId('class-outer').textContent).toBe('items-list-outer'); + expect(getByTestId('class-list').textContent).toBe('items-list'); + }); + + test('Invokes addListItems when items change', () => { + const { rerender } = render(); + expect(addListItemsSpy).toHaveBeenCalledTimes(1); + rerender(); + expect(addListItemsSpy).toHaveBeenCalledTimes(2); + }); + + test('Renders nothing in renderBeforeListWrap and renderAfterListWrap', () => { + const { getByTestId } = render( + + ); + expect(getByTestId('render-before').textContent).toBe(''); + expect(getByTestId('render-after').textContent).toBe(''); + }); + + test('Does not call listHandler.loadItems when refs are not attached', () => { + render(); + expect(mockListHandler.loadItems).not.toHaveBeenCalled(); + }); + + test('Calls listHandler.loadItems when refs are set and scroll threshold is reached', async () => { + render(); + await waitFor(() => { + expect(mockListHandler.loadItems).toHaveBeenCalled(); + }); + }); + + test('Calls PageStore.removeListener when refs are set and loadedAllItems is true', () => { + render(); + expect(mockRemoveListener).toHaveBeenCalledWith('window_scroll', expect.any(Function)); + }); + + test('onDocumentVisibilityChange schedules onWindowScroll when document is visible', () => { + jest.useFakeTimers(); + const setTimeoutSpy = jest.spyOn(globalThis, 'setTimeout'); + Object.defineProperty(document, 'hidden', { value: false, configurable: true }); + + const { getByTestId } = render(); + fireEvent.click(getByTestId('trigger-visibility')); + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 10); + + setTimeoutSpy.mockRestore(); + jest.useRealTimers(); + }); + + test('onDocumentVisibilityChange does nothing when document is hidden', () => { + jest.useFakeTimers(); + const setTimeoutSpy = jest.spyOn(globalThis, 'setTimeout'); + Object.defineProperty(document, 'hidden', { value: true, configurable: true }); + + const { getByTestId } = render(); + fireEvent.click(getByTestId('trigger-visibility')); + + expect(setTimeoutSpy).toHaveBeenCalledTimes(0); + + setTimeoutSpy.mockRestore(); + jest.useRealTimers(); + }); + }); +}); diff --git a/frontend/tests/utils/hooks/useItemListSync.test.tsx b/frontend/tests/utils/hooks/useItemListSync.test.tsx new file mode 100644 index 00000000..b9f7d827 --- /dev/null +++ b/frontend/tests/utils/hooks/useItemListSync.test.tsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; + +jest.mock('../../../src/static/js/utils/settings/config', () => ({ + config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig), +})); + +jest.mock('../../../src/static/js/utils/classes/', () => ({ + BrowserCache: jest.fn().mockImplementation(() => ({ + get: jest.fn(), + set: jest.fn(), + })), +})); + +jest.mock('../../../src/static/js/utils/helpers/', () => ({ + translateString: (s: string) => s, +})); + +let mockListHandler: any; +let mockOnItemsLoad = jest.fn(); +let mockOnItemsCount = jest.fn(); +let addListItemsSpy = jest.fn(); + +// Mock useItemList to control items, counts, and listHandler +jest.mock('../../../src/static/js/utils/hooks/useItemList', () => ({ + useItemList: (props: any, _ref: any) => { + mockListHandler = { + loadItems: jest.fn(), + totalPages: jest.fn().mockReturnValue(props.__totalPages ?? 1), + loadedAllItems: jest.fn().mockReturnValue(Boolean(props.__loadedAll ?? true)), + }; + return [ + props.__items ?? [], // items + props.__countedItems ?? 0, // countedItems + mockListHandler, // listHandler + jest.fn(), // setListHandler + mockOnItemsLoad, // onItemsLoad + mockOnItemsCount, // onItemsCount + addListItemsSpy, // addListItems + ]; + }, +})); + +import { useItemListSync } from '../../../src/static/js/utils/hooks/useItemListSync'; + +function HookConsumer(props: any) { + const tuple = useItemListSync(props); + + const [ + _countedItems, + _items, + _listHandler, + _setListHandler, + classname, + _itemsListWrapperRef, + _itemsListRef, + _onItemsCount, + _onItemsLoad, + renderBeforeListWrap, + renderAfterListWrap, + ] = tuple as any; + + return ( +
+ {/*
{String(countedItems)}
*/} + {/*
{Array.isArray(items) ? items.length : 0}
*/} +
{classname.list}
+
{classname.listOuter}
+ {/*
{listHandler ? 'yes' : 'no'}
*/} + {/*
{itemsListWrapperRef.current ? 'set' : 'unset'}
*/} + {/*
{itemsListRef.current ? 'set' : 'unset'}
*/} +
{renderBeforeListWrap()}
+
{renderAfterListWrap()}
+ {/*
+ ); +} + +describe('utils/hooks', () => { + describe('useItemListSync', () => { + beforeEach(() => { + mockOnItemsLoad = jest.fn(); + mockOnItemsCount = jest.fn(); + addListItemsSpy = jest.fn(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Classname Management', () => { + test('Computes classname.listOuter with optional className prop', () => { + const { getByTestId, rerender } = render(); + expect(getByTestId('class-outer').textContent).toBe('items-list-outer extra'); + expect(getByTestId('class-list').textContent).toBe('items-list'); + rerender(); + expect(getByTestId('class-outer').textContent).toBe('items-list-outer'); + expect(getByTestId('class-list').textContent).toBe('items-list'); + }); + }); + + describe('Items Management', () => { + test('Invokes addListItems and afterItemsLoad when items change', () => { + const { rerender } = render(); + expect(addListItemsSpy).toHaveBeenCalledTimes(1); + rerender(); + // useEffect runs again due to items change + expect(addListItemsSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe('Load More Button Rendering', () => { + test('Renders SHOW MORE button when more pages exist and not loaded all', () => { + const { getByTestId } = render( + + ); + const btn = getByTestId('render-after').querySelector('button.load-more') as HTMLButtonElement; + expect(btn).toBeTruthy(); + expect(btn.textContent).toBe('SHOW MORE'); + fireEvent.click(btn); + expect(mockListHandler.loadItems).toHaveBeenCalledTimes(1); + }); + + test('Hides SHOW MORE when totalPages <= 1', () => { + const { getByTestId } = render( + // With totalPages=1 the hook should not render the button regardless of loadedAll + + ); + expect(getByTestId('render-after').textContent).toBe(''); + }); + + test('Hides SHOW MORE when loadedAllItems is true', () => { + const { getByTestId } = render( + + ); + expect(getByTestId('render-after').textContent).toBe(''); + }); + + test('Shows SHOW MORE when loadedAllItems is false even with totalPages > 1', () => { + const { getByTestId } = render( + + ); + const btn = getByTestId('render-after').querySelector('button.load-more'); + expect(btn).toBeTruthy(); + }); + + test('Returns null from renderBeforeListWrap', () => { + const { getByTestId } = render( + + ); + expect(getByTestId('render-before').textContent).toBe(''); + }); + }); + }); +}); diff --git a/frontend/tests/utils/hooks/useLayout.test.tsx b/frontend/tests/utils/hooks/useLayout.test.tsx new file mode 100644 index 00000000..eb18d249 --- /dev/null +++ b/frontend/tests/utils/hooks/useLayout.test.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import { act, render } from '@testing-library/react'; + +import { useLayout } from '../../../src/static/js/utils/hooks/useLayout'; + +jest.mock('../../../src/static/js/utils/classes/', () => ({ + BrowserCache: jest.fn().mockImplementation(() => ({ + get: (key: string) => { + let result: any = undefined; + switch (key) { + case 'visible-sidebar': + result = true; + break; + } + return result; + }, + set: jest.fn(), + })), +})); + +jest.mock('../../../src/static/js/utils/dispatcher.js', () => ({ + register: jest.fn(), +})); + +jest.mock('../../../src/static/js/utils/settings/config', () => ({ + config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig), +})); + +import { LayoutProvider } from '../../../src/static/js/utils/contexts'; + +describe('utils/hooks', () => { + describe('useLayout', () => { + test('Returns default value', () => { + let received: ReturnType | undefined; + + const Comp: React.FC = () => { + received = useLayout(); + return null; + }; + + render( + + + + ); + + expect(received).toStrictEqual({ + enabledSidebar: false, + visibleSidebar: true, + visibleMobileSearch: false, + setVisibleSidebar: expect.any(Function), + toggleMobileSearch: expect.any(Function), + toggleSidebar: expect.any(Function), + }); + }); + + test('Returns undefined value when used without a Provider', () => { + let received: any = 'init'; + + const Comp: React.FC = () => { + received = useLayout(); + return null; + }; + + render(); + + expect(received).toBe(undefined); + }); + + test('Toggle sidebar', () => { + jest.useFakeTimers(); + + let received: ReturnType | undefined; + + const Comp: React.FC = () => { + received = useLayout(); + return null; + }; + + render( + + + + ); + + act(() => received?.toggleSidebar()); + jest.advanceTimersByTime(241); + expect(received?.visibleSidebar).toBe(false); + + act(() => received?.toggleSidebar()); + jest.advanceTimersByTime(241); + expect(received?.visibleSidebar).toBe(true); + + jest.useRealTimers(); + }); + + test('Toggle mobile search', () => { + let received: ReturnType | undefined; + + const Comp: React.FC = () => { + received = useLayout(); + return null; + }; + + render( + + + + ); + + act(() => received?.toggleMobileSearch()); + expect(received?.visibleMobileSearch).toBe(true); + + act(() => received?.toggleMobileSearch()); + expect(received?.visibleMobileSearch).toBe(false); + }); + }); +}); diff --git a/frontend/tests/utils/hooks/useManagementTableHeader.test.tsx b/frontend/tests/utils/hooks/useManagementTableHeader.test.tsx new file mode 100644 index 00000000..da9f7704 --- /dev/null +++ b/frontend/tests/utils/hooks/useManagementTableHeader.test.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { useManagementTableHeader } from '../../../src/static/js/utils/hooks/useManagementTableHeader'; + +function HookConsumer(props: { + order: 'asc' | 'desc'; + selected: boolean; + sort: string; + type: 'comments' | 'media' | 'users'; + onCheckAllRows?: (newSort: string, newOrder: 'asc' | 'desc') => void; + onClickColumnSort?: (newSelected: boolean, newType: 'comments' | 'media' | 'users') => void; +}) { + const tuple = useManagementTableHeader(props) as [ + string, + 'asc' | 'desc', + boolean, + React.MouseEventHandler, + () => void, + ]; + + const [sort, order, isSelected, sortByColumn, checkAll] = tuple; + + return ( +
+
{sort}
+
{order}
+
{String(isSelected)}
+
+ ); +} + +describe('utils/hooks', () => { + describe('useManagementTableHeader', () => { + test('Returns a 5-tuple in expected order and reflects initial props', () => { + let tuple: any; + + const Comp: React.FC = () => { + tuple = useManagementTableHeader({ sort: 'title', order: 'asc', selected: false }); + return null; + }; + + render(); + + expect(Array.isArray(tuple)).toBe(true); + expect(tuple).toHaveLength(5); + + const [sort, order, isSelected] = tuple; + + expect(sort).toBe('title'); + expect(order).toBe('asc'); + expect(isSelected).toBe(false); + }); + + test('sortByColumn toggles order when clicking same column and updates sort when clicking different column', () => { + const onClickColumnSort = jest.fn(); + + const { getByTestId, rerender } = render( + + ); + + // Initial state + expect(getByTestId('sort').textContent).toBe('title'); + expect(getByTestId('order').textContent).toBe('desc'); + + // Click same column -> toggle order to asc + fireEvent.click(getByTestId('col-title')); + expect(onClickColumnSort).toHaveBeenLastCalledWith('title', 'asc'); + + // Rerender to ensure state settled in testing DOM + rerender( + + ); + + // Click same column -> toggle order to desc + fireEvent.click(getByTestId('col-title')); + expect(onClickColumnSort).toHaveBeenLastCalledWith('title', 'desc'); + + // Click different column -> set sort to that column and default order desc + fireEvent.click(getByTestId('col-views')); + expect(onClickColumnSort).toHaveBeenLastCalledWith('views', 'desc'); + }); + + test('checkAll inverts current selection and invokes callback with newSelected and type', () => { + const onCheckAllRows = jest.fn(); + + const { getByTestId } = render( + + ); + + expect(getByTestId('selected').textContent).toBe('false'); + fireEvent.click(getByTestId('check-all')); + + // newSelected computed as !isSelected -> true + expect(onCheckAllRows).toHaveBeenCalledWith(true, 'media'); + }); + + test('Effects update internal state when props change', () => { + const { getByTestId, rerender } = render( + + ); + + expect(getByTestId('sort').textContent).toBe('title'); + expect(getByTestId('order').textContent).toBe('asc'); + expect(getByTestId('selected').textContent).toBe('false'); + + rerender(); + + expect(getByTestId('sort').textContent).toBe('views'); + expect(getByTestId('order').textContent).toBe('desc'); + expect(getByTestId('selected').textContent).toBe('true'); + }); + + test('Does not throw when optional callbacks are not provided', () => { + const { getByTestId } = render(); + expect(() => fireEvent.click(getByTestId('col-title'))).not.toThrow(); + expect(() => fireEvent.click(getByTestId('check-all'))).not.toThrow(); + }); + }); +}); diff --git a/frontend/tests/utils/hooks/useMediaFilter.test.tsx b/frontend/tests/utils/hooks/useMediaFilter.test.tsx new file mode 100644 index 00000000..4296c76b --- /dev/null +++ b/frontend/tests/utils/hooks/useMediaFilter.test.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { useMediaFilter } from '../../../src/static/js/utils/hooks/useMediaFilter'; + +jest.mock('../../../src/static/js/components/_shared/popup/PopupContent', () => ({ + PopupContent: (props: any) => React.createElement('div', props, props.children), +})); + +jest.mock('../../../src/static/js/components/_shared/popup/PopupTrigger', () => ({ + PopupTrigger: (props: any) => React.createElement('div', props, props.children), +})); + +function HookConsumer({ initial }: { initial: string }) { + const tuple = useMediaFilter(initial) as [ + React.RefObject, + string, + React.Dispatch>, + React.RefObject, + React.ReactNode, + React.ReactNode, + ]; + + const [containerRef, value, setValue, popupContentRef, PopupContent, PopupTrigger] = tuple; + + return ( +
+
{containerRef && typeof containerRef === 'object' ? 'ok' : 'bad'}
+
{value}
+
+ ); +} + +describe('utils/hooks', () => { + describe('useMediaFilter', () => { + test('Returns a 6-tuple in expected order', () => { + let tuple: any; + + const Comp: React.FC = () => { + tuple = useMediaFilter('init'); + return null; + }; + + render(); + + expect(Array.isArray(tuple)).toBe(true); + expect(tuple).toHaveLength(6); + + const [containerRef, value, setValue, popupContentRef, PopupContent, PopupTrigger] = tuple; + + expect(containerRef).toBeDefined(); + expect(containerRef.current).toBe(null); + expect(value).toBe('init'); + expect(typeof setValue).toBe('function'); + expect(popupContentRef).toBeDefined(); + expect(typeof PopupContent).toBe('function'); + expect(typeof PopupTrigger).toBe('function'); + }); + + test('Initial value is respected and can be updated via setter', () => { + const { getByTestId } = render(); + expect(getByTestId('value').textContent).toBe('first'); + getByTestId('set').click(); + expect(getByTestId('value').textContent).toBe('updated'); + }); + + test('containerRef and popupContentRef are mutable ref objects', () => { + let data: any; + + const Comp: React.FC = () => { + data = useMediaFilter('x'); + return null; + }; + + render(); + + const [containerRef, _value, _setValue, popupContentRef] = data; + + expect(containerRef.current).toBe(null); + expect(popupContentRef.current).toBe(null); + }); + + test('PopupContent and PopupTrigger are stable functions', () => { + let first: any; + let second: any; + + const First: React.FC = () => { + first = useMediaFilter('a'); + return null; + }; + + const Second: React.FC = () => { + second = useMediaFilter('b'); + return null; + }; + + const Parent: React.FC = () => ( + <> + + + + ); + + render(); + + const [, , , , PopupContent1, PopupTrigger1] = first; + const [, , , , PopupContent2, PopupTrigger2] = second; + + expect(typeof PopupContent1).toBe('function'); + expect(typeof PopupTrigger1).toBe('function'); + + expect(PopupContent1).toBe(PopupContent2); + expect(PopupTrigger1).toBe(PopupTrigger2); + }); + }); +}); diff --git a/frontend/tests/utils/hooks/useMediaItem.test.tsx b/frontend/tests/utils/hooks/useMediaItem.test.tsx new file mode 100644 index 00000000..9dd035db --- /dev/null +++ b/frontend/tests/utils/hooks/useMediaItem.test.tsx @@ -0,0 +1,289 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { useMediaItem, itemClassname } from '../../../src/static/js/utils/hooks/useMediaItem'; + +// Mock dependencies used by useMediaItem + +// @todo: Revisit this +jest.mock('../../../src/static/js/utils/stores/', () => ({ + PageStore: { get: (_: string) => ({ url: 'https://example.com' }) }, +})); + +jest.mock('../../../src/static/js/components/list-item/includes/items', () => ({ + MediaItemAuthor: ({ name }: any) =>
, + MediaItemAuthorLink: ({ name, link }: any) => ( + + ), + MediaItemMetaViews: ({ views }: any) => , + MediaItemMetaDate: ({ time, dateTime, text }: any) => ( + + ), + MediaItemEditLink: ({ link }: any) => , + MediaItemViewLink: ({ link }: any) => , +})); + +// @todo: Revisit this +// useItem returns titleComponent, descriptionComponent, thumbnailUrl, UnderThumbWrapper +jest.mock('../../../src/static/js/utils/hooks/useItem', () => ({ + useItem: (props: any) => ({ + titleComponent: () =>

{props.title || 'title'}

, + descriptionComponent: () =>

{props.description || 'desc'}

, + thumbnailUrl: props.thumb || 'thumb.jpg', + UnderThumbWrapper: ({ children }: any) =>
{children}
, + }), +})); + +function HookConsumer(props: any) { + const [TitleComp, DescComp, thumbUrl, UnderThumbComp, EditComp, MetaComp, ViewComp] = useMediaItem(props); + // The hook returns functions/components/values. To satisfy TS, render using React.createElement + return ( +
+ {typeof TitleComp === 'function' ? React.createElement(TitleComp) : null} + {typeof DescComp === 'function' ? React.createElement(DescComp) : null} +
{typeof thumbUrl === 'string' ? thumbUrl : ''}
+ {typeof UnderThumbComp === 'function' + ? React.createElement( + UnderThumbComp, + null, + typeof EditComp === 'function' ? React.createElement(EditComp) : null, + typeof MetaComp === 'function' ? React.createElement(MetaComp) : null, + typeof ViewComp === 'function' ? React.createElement(ViewComp) : null + ) + : null} +
+ ); +} + +describe('utils/hooks', () => { + describe('useMediaItem', () => { + describe('itemClassname utility function', () => { + test('Returns default classname when no modifications', () => { + expect(itemClassname('base', '', false)).toBe('base'); + }); + + test('Appends inherited classname when provided', () => { + expect(itemClassname('base', 'extra', false)).toBe('base extra'); + }); + + test('Appends pl-active-item when isActiveInPlaylistPlayback is true', () => { + expect(itemClassname('base', '', true)).toBe('base pl-active-item'); + }); + + test('Appends both inherited classname and active state', () => { + expect(itemClassname('base', 'extra', true)).toBe('base extra pl-active-item'); + }); + }); + + describe('Basic Rendering', () => { + test('Renders basic components from useItem and edit/view links', () => { + // @todo: Revisit this + const props = { + title: 'My Title', + description: 'My Desc', + thumbnail: 'thumb.jpg', + link: '/watch/1', + singleLinkContent: true, + // hasMediaViewer:... + // hasMediaViewerDescr:... + // meta_description:... + // onMount:... + // type:... + // ------------------------------ + editLink: '/edit/1', + showSelection: true, + // publishLink: ... + // hideAuthor:... + author_name: 'Author', + author_link: '/u/author', + // hideViews:... + views: 10, + // hideDate:... + publish_date: '2020-01-01T00:00:00Z', + // hideAllMeta:... + }; + + const { getByTestId, queryByTestId } = render(); + + expect(getByTestId('title').textContent).toBe(props.title); + expect(getByTestId('desc').textContent).toBe(props.description); + expect(getByTestId('thumb').textContent).toBe('thumb.jpg'); + + expect(getByTestId('edit').getAttribute('href')).toBe(props.editLink); + + expect(getByTestId('views').getAttribute('data-views')).toBe(props.views.toString()); + expect(getByTestId('date')).toBeTruthy(); + expect(getByTestId('view').getAttribute('href')).toBe(props.link); + expect(queryByTestId('author')).toBeTruthy(); + }); + }); + + describe('View Link Selection', () => { + test('Uses publishLink when provided and showSelection=true', () => { + const props = { + editLink: '/edit/2', + link: '/watch/2', + publishLink: '/publish/2', + showSelection: true, + singleLinkContent: true, + author_name: 'A', + author_link: '', + views: 0, + publish_date: 0, + }; + + const { getByTestId } = render(); + + expect(getByTestId('view').getAttribute('href')).toBe(props.publishLink); + }); + }); + + describe('Visibility Controls', () => { + test('Hides author, views, and date based on props', () => { + const props = { + editLink: '/e', + link: '/l', + showSelection: true, + hideAuthor: true, + hideViews: true, + hideDate: true, + publish_date: '2020-01-01T00:00:00Z', + views: 5, + author_name: 'Hidden', + author_link: '/u/x', + }; + + const { queryByTestId } = render(); + + expect(queryByTestId('author')).toBeNull(); + expect(queryByTestId('views')).toBeNull(); + expect(queryByTestId('date')).toBeNull(); + }); + + test('Author link resolves using formatInnerLink and PageStore base url when singleLinkContent=false', () => { + const props = { + editLink: '/e', + link: '/l', + showSelection: true, + singleLinkContent: false, + hideAuthor: false, + author_name: 'John', + author_link: '/u/john', + publish_date: '2020-01-01T00:00:00Z', + }; + + const { container } = render(); + + const a = container.querySelector('[data-testid="author-link"]') as HTMLAnchorElement; + + expect(a).toBeTruthy(); + expect(a.getAttribute('href')).toBe(`https://example.com${props.author_link}`); + expect(a.getAttribute('data-name')).toBe(props.author_name); + }); + }); + + describe('Meta Visibility', () => { + test('Meta wrapper hidden when hideAllMeta=true', () => { + const props = { + editLink: '/e', + link: '/l', + showSelection: true, + hideAllMeta: true, + publish_date: '2020-01-01T00:00:00Z', + }; + + const { queryByTestId } = render(); + + expect(queryByTestId('author')).toBeNull(); + expect(queryByTestId('views')).toBeNull(); + expect(queryByTestId('date')).toBeNull(); + }); + + test('Meta wrapper hidden individually by hideAuthor, hideViews, hideDate', () => { + const props = { + editLink: '/e', + link: '/l', + showSelection: true, + hideAuthor: true, + hideViews: false, + hideDate: false, + publish_date: '2020-01-01T00:00:00Z', + views: 5, + author_name: 'Test', + author_link: '/u/test', + }; + + const { queryByTestId } = render(); + + expect(queryByTestId('author')).toBeNull(); + expect(queryByTestId('views')).toBeTruthy(); + expect(queryByTestId('date')).toBeTruthy(); + }); + }); + + describe('Edge Cases & Date Handling', () => { + test('Handles views when hideViews is false', () => { + const props = { + editLink: '/e', + link: '/l', + showSelection: true, + hideViews: false, + views: 100, + publish_date: '2020-01-01T00:00:00Z', + author_name: 'A', + author_link: '/u/a', + }; + + const { getByTestId } = render(); + expect(getByTestId('views')).toBeTruthy(); + expect(getByTestId('views').getAttribute('data-views')).toBe('100'); + }); + + test('Renders without showSelection', () => { + const props = { + editLink: '/e', + link: '/l', + showSelection: false, + publish_date: '2020-01-01T00:00:00Z', + author_name: 'A', + author_link: '/u/a', + }; + + const { queryByTestId } = render(); + expect(queryByTestId('view')).toBeNull(); + }); + + test('Handles numeric publish_date correctly', () => { + const props = { + editLink: '/e', + link: '/l', + showSelection: true, + publish_date: 1577836800000, // 2020-01-01 as timestamp + author_name: 'A', + author_link: '/u/a', + }; + + const { getByTestId } = render(); + expect(getByTestId('date')).toBeTruthy(); + }); + + test('Handles empty author_link by setting it to null', () => { + const props = { + editLink: '/e', + link: '/l', + showSelection: true, + singleLinkContent: false, + author_name: 'Anonymous', + author_link: '', // Empty link + publish_date: '2020-01-01T00:00:00Z', + }; + + const { container } = render(); + const authorLink = container.querySelector('[data-testid="author-link"]') as HTMLAnchorElement; + expect(authorLink).toBeTruthy(); + expect(authorLink.getAttribute('href')).toBeNull(); + }); + }); + }); +}); diff --git a/frontend/tests/utils/hooks/usePopup.test.tsx b/frontend/tests/utils/hooks/usePopup.test.tsx new file mode 100644 index 00000000..92f1ea3e --- /dev/null +++ b/frontend/tests/utils/hooks/usePopup.test.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +// Mock popup components to avoid SCSS imports breaking Jest +jest.mock('../../../src/static/js/components/_shared/popup/Popup.jsx', () => { + const React = require('react'); + const Popup = React.forwardRef((props: any, _ref: any) => React.createElement('div', props, props.children)); + return { __esModule: true, default: Popup }; +}); + +jest.mock('../../../src/static/js/components/_shared/popup/PopupContent.jsx', () => ({ + PopupContent: (props: any) => React.createElement('div', props, props.children), +})); + +jest.mock('../../../src/static/js/components/_shared/popup/PopupTrigger.jsx', () => ({ + PopupTrigger: (props: any) => React.createElement('div', props, props.children), +})); + +import { usePopup } from '../../../src/static/js/utils/hooks/usePopup'; + +describe('utils/hooks', () => { + describe('usePopup', () => { + test('Returns a 3-tuple: [ref, PopupContent, PopupTrigger]', () => { + let value: any; + + const Comp: React.FC = () => { + value = usePopup(); + return null; + }; + + render(); + + expect(Array.isArray(value)).toBe(true); + expect(value).toHaveLength(3); + + const [ref, PopupContent, PopupTrigger] = value; + + expect(ref).toBeDefined(); + expect(ref.current).toBe(null); + + expect(typeof PopupContent).toBe('function'); + expect(typeof PopupTrigger).toBe('function'); + }); + + test('Tuple components are stable functions and refs are unique per call', () => { + let results: any[] = []; + + const Comp: React.FC = () => { + results.push(usePopup()); + results.push(usePopup()); + return null; + }; + + render(); + + const [ref1, PopupContent1, PopupTrigger1] = results[0]; + const [ref2, PopupContent2, PopupTrigger2] = results[1]; + + expect(typeof PopupContent1).toBe('function'); + expect(typeof PopupTrigger1).toBe('function'); + + expect(PopupContent1).toBe(PopupContent2); + expect(PopupTrigger1).toBe(PopupTrigger2); + + expect(ref1).not.toBe(ref2); + }); + }); +}); diff --git a/frontend/tests/utils/hooks/useTheme.test.tsx b/frontend/tests/utils/hooks/useTheme.test.tsx new file mode 100644 index 00000000..b3d44065 --- /dev/null +++ b/frontend/tests/utils/hooks/useTheme.test.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { act, render } from '@testing-library/react'; + +import { useTheme as useThemeHook } from '../../../src/static/js/utils/hooks/useTheme'; + +import { sampleMediaCMSConfig } from '../../tests-constants'; + +jest.mock('../../../src/static/js/utils/classes/', () => ({ + BrowserCache: jest.fn().mockImplementation(() => ({ + get: jest.fn(), + set: jest.fn(), + })), +})); + +jest.mock('../../../src/static/js/utils/dispatcher.js', () => ({ + register: jest.fn(), +})); + +function getRenderers(ThemeProvider: React.FC<{ children: React.ReactNode }>, useTheme: typeof useThemeHook) { + const data: { current: any } = { current: undefined }; + + const Comp: React.FC = () => { + data.current = useTheme(); + return null; + }; + + const wrapper: typeof ThemeProvider = ({ children }) => {children}; + + return { Comp, wrapper, data }; +} + +function getThemeConfig(override?: { + logo?: Partial<(typeof sampleMediaCMSConfig.theme)['logo']>; + mode?: (typeof sampleMediaCMSConfig.theme)['mode']; + switch?: Partial<(typeof sampleMediaCMSConfig.theme)['switch']>; +}) { + const { logo, mode, switch: sw } = override ?? {}; + const { lightMode, darkMode } = logo ?? {}; + + const config = { + logo: { + lightMode: { img: lightMode?.img ?? '/img/light.png', svg: lightMode?.svg ?? '/img/light.svg' }, + darkMode: { img: darkMode?.img ?? '/img/dark.png', svg: darkMode?.svg ?? '/img/dark.svg' }, + }, + mode: mode ?? 'dark', + switch: { enabled: sw?.enabled ?? true, position: sw?.position ?? 'sidebar' }, + }; + + return config; +} + +describe('utils/hooks', () => { + afterEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + }); + + describe('useTheme', () => { + const themeConfig = getThemeConfig(); + const darkThemeConfig = getThemeConfig({ mode: 'dark' }); + + // @todo: Revisit this + test.each([ + [ + darkThemeConfig, + { + logo: darkThemeConfig.logo.darkMode.svg, + currentThemeMode: darkThemeConfig.mode, + changeThemeMode: expect.any(Function), + themeModeSwitcher: themeConfig.switch, + }, + ], + ])('Validate value', async (theme, expectedResult) => { + jest.doMock('../../../src/static/js/utils/settings/config', () => ({ + config: jest.fn(() => ({ ...jest.requireActual('../../tests-constants').sampleMediaCMSConfig, theme })), + })); + + const { ThemeProvider } = await import('../../../src/static/js/utils/contexts/ThemeContext'); + const { useTheme } = await import('../../../src/static/js/utils/hooks/useTheme'); + + const { Comp, wrapper, data } = getRenderers(ThemeProvider, useTheme); + + render(, { wrapper }); + + expect(data.current).toStrictEqual(expectedResult); + + act(() => data.current.changeThemeMode()); + + const newThemeMode = 'light' === expectedResult.currentThemeMode ? 'dark' : 'light'; + const newThemeLogo = + 'light' === newThemeMode ? themeConfig.logo.lightMode.svg : themeConfig.logo.darkMode.svg; + + expect(data.current).toStrictEqual({ + ...expectedResult, + logo: newThemeLogo, + currentThemeMode: newThemeMode, + }); + }); + }); +}); diff --git a/frontend/tests/utils/hooks/useUser.test.tsx b/frontend/tests/utils/hooks/useUser.test.tsx new file mode 100644 index 00000000..ad50f10c --- /dev/null +++ b/frontend/tests/utils/hooks/useUser.test.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import { UserProvider } from '../../../src/static/js/utils/contexts/UserContext'; +import { useUser } from '../../../src/static/js/utils/hooks/useUser'; +import { sampleMediaCMSConfig } from '../../tests-constants'; + +jest.mock('../../../src/static/js/utils/settings/config', () => ({ + config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig), +})); + +function getRenderers() { + const data: { current: any } = { current: undefined }; + + const Comp: React.FC = () => { + data.current = useUser(); + return null; + }; + + const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => {children}; + + return { Comp, wrapper, data }; +} + +describe('utils/hooks', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('useUser', () => { + test('Validate value', () => { + const { Comp, wrapper, data } = getRenderers(); + + render(, { wrapper }); + + expect(data.current).toStrictEqual({ + isAnonymous: sampleMediaCMSConfig.member.is.anonymous, + username: sampleMediaCMSConfig.member.username, + thumbnail: sampleMediaCMSConfig.member.thumbnail, + userCan: sampleMediaCMSConfig.member.can, + pages: sampleMediaCMSConfig.member.pages, + }); + }); + }); +});