useCompleteOnboarding.spec.js
2 months ago
useDateRangePicker.spec.js
3 months ago
useEmail.spec.js
3 months ago
useEngagementChartData.spec.js
3 months ago
useLicenseSettings.spec.js
3 months ago
useLink.spec.js
3 months ago
useMediaDetail.spec.js
3 months ago
useMediaLibrary.spec.js
3 months ago
useMediaList.spec.js
2 months ago
usePerformanceSettings.spec.js
3 months ago
useRegisterActivePage.spec.js
3 months ago
useSettingOption.spec.js
3 months ago
useSimpleSettingsPage.spec.js
3 months ago
useTopPerforming.spec.js
3 months ago
useTopVideosPaginated.spec.js
3 months ago
useUpgradeCTA.spec.js
3 months ago
useUserDetail.spec.js
3 months ago
useMediaList.spec.js
300 lines
| 1 | import { renderHook, act } from '@testing-library/react-hooks'; |
| 2 | import apiFetch from '@wordpress/api-fetch'; |
| 3 | import useMediaList from '../useMediaList'; |
| 4 | |
| 5 | jest.mock( '@wordpress/api-fetch' ); |
| 6 | |
| 7 | // Minimal canned response matching /presto-player/v1/media-list envelope. |
| 8 | // `ok` mirrors `Response.ok` so the hook's HTTP-error path can be exercised. |
| 9 | const mockResponse = ( { |
| 10 | items = [], |
| 11 | total = items.length, |
| 12 | totalPages = total ? Math.max( 1, Math.ceil( total / 25 ) ) : 0, |
| 13 | counts = { |
| 14 | publish: total, |
| 15 | draft: 0, |
| 16 | pending: 0, |
| 17 | private: 0, |
| 18 | future: 0, |
| 19 | trash: 0, |
| 20 | }, |
| 21 | allTags = [], |
| 22 | ok = true, |
| 23 | status = 200, |
| 24 | } = {} ) => ( { |
| 25 | ok, |
| 26 | status, |
| 27 | headers: { |
| 28 | get: ( h ) => { |
| 29 | if ( h === 'X-WP-Total' ) { |
| 30 | return String( total ); |
| 31 | } |
| 32 | if ( h === 'X-WP-TotalPages' ) { |
| 33 | return String( totalPages ); |
| 34 | } |
| 35 | return null; |
| 36 | }, |
| 37 | }, |
| 38 | json: async () => ( { |
| 39 | items, |
| 40 | total, |
| 41 | total_pages: totalPages, |
| 42 | page: 1, |
| 43 | per_page: 25, |
| 44 | counts, |
| 45 | all_tags: allTags, |
| 46 | } ), |
| 47 | } ); |
| 48 | |
| 49 | // apiFetch with `parse: false` rejects with the raw Response (not a |
| 50 | // thrown Error) on non-2xx. Mimic the shape the hook duck-types against: |
| 51 | // `{ status: number, json: () => Promise<envelope> }`. Avoiding the real |
| 52 | // `Response` constructor here keeps the spec compatible with jsdom, which |
| 53 | // doesn't ship one. |
| 54 | const mockHttpError = ( { status = 401, code = 'rest_forbidden', message = 'Sorry, you are not allowed.' } = {} ) => ( { |
| 55 | status, |
| 56 | headers: { get: () => null }, |
| 57 | json: async () => ( { code, message, data: { status } } ), |
| 58 | } ); |
| 59 | |
| 60 | const buildItem = ( id, title, overrides = {} ) => ( { |
| 61 | id, |
| 62 | title, |
| 63 | status: 'publish', |
| 64 | date: '2026-05-01T10:00:00', |
| 65 | modified: '2026-05-01T10:00:00', |
| 66 | post_name: `video-${ id }`, |
| 67 | post_password: '', |
| 68 | shortcode: `[presto_player id=${ id }]`, |
| 69 | poster: '', |
| 70 | author: { id: 1, name: 'Admin' }, |
| 71 | tags: [], |
| 72 | link: '', |
| 73 | ...overrides, |
| 74 | } ); |
| 75 | |
| 76 | beforeEach( () => { |
| 77 | apiFetch.mockReset(); |
| 78 | } ); |
| 79 | |
| 80 | describe( 'useMediaList', () => { |
| 81 | it( 'fetches once on mount with the default params', async () => { |
| 82 | apiFetch.mockResolvedValue( |
| 83 | mockResponse( { items: [ buildItem( 1, 'Hello' ) ] } ) |
| 84 | ); |
| 85 | |
| 86 | const { result, waitForNextUpdate } = renderHook( () => useMediaList() ); |
| 87 | await waitForNextUpdate(); |
| 88 | |
| 89 | expect( apiFetch ).toHaveBeenCalledTimes( 1 ); |
| 90 | const call = apiFetch.mock.calls[ 0 ][ 0 ]; |
| 91 | expect( call.parse ).toBe( false ); |
| 92 | expect( call.path ).toContain( '/presto-player/v1/media-list' ); |
| 93 | expect( call.path ).toContain( 'page=1' ); |
| 94 | expect( call.path ).toContain( 'per_page=25' ); |
| 95 | expect( call.path ).toContain( 'orderby=date' ); |
| 96 | expect( call.path ).toContain( 'order=desc' ); |
| 97 | expect( result.current.items ).toHaveLength( 1 ); |
| 98 | expect( result.current.items[ 0 ].title ).toBe( 'Hello' ); |
| 99 | expect( result.current.totalItems ).toBe( 1 ); |
| 100 | } ); |
| 101 | |
| 102 | it( 'rebuilds the request when params change', async () => { |
| 103 | apiFetch.mockResolvedValue( mockResponse( { items: [] } ) ); |
| 104 | |
| 105 | const { rerender, waitForNextUpdate } = renderHook( |
| 106 | ( props ) => useMediaList( props ), |
| 107 | { initialProps: { search: '', page: 1 } } |
| 108 | ); |
| 109 | await waitForNextUpdate(); |
| 110 | |
| 111 | rerender( { search: 'lorem', page: 1 } ); |
| 112 | await waitForNextUpdate(); |
| 113 | |
| 114 | const last = apiFetch.mock.calls.at( -1 )[ 0 ]; |
| 115 | expect( last.path ).toContain( 'search=lorem' ); |
| 116 | } ); |
| 117 | |
| 118 | it( 'aborts an in-flight request when params change', async () => { |
| 119 | const abortSpy = jest.fn(); |
| 120 | apiFetch.mockImplementation( |
| 121 | () => |
| 122 | new Promise( () => { |
| 123 | /* never resolves */ |
| 124 | } ) |
| 125 | ); |
| 126 | // Capture the signal from each call. |
| 127 | const seenSignals = []; |
| 128 | apiFetch.mockImplementation( ( { signal } ) => { |
| 129 | seenSignals.push( signal ); |
| 130 | signal.addEventListener( 'abort', abortSpy ); |
| 131 | return new Promise( () => {} ); |
| 132 | } ); |
| 133 | |
| 134 | const { rerender } = renderHook( ( props ) => useMediaList( props ), { |
| 135 | initialProps: { search: '' }, |
| 136 | } ); |
| 137 | |
| 138 | await act( async () => { |
| 139 | rerender( { search: 'lorem' } ); |
| 140 | } ); |
| 141 | |
| 142 | // The first signal should have been aborted when the second fetch started. |
| 143 | expect( abortSpy ).toHaveBeenCalled(); |
| 144 | expect( seenSignals[ 0 ].aborted ).toBe( true ); |
| 145 | } ); |
| 146 | |
| 147 | it( 'surfaces error and clears items on a non-abort failure', async () => { |
| 148 | apiFetch.mockRejectedValue( new Error( 'boom' ) ); |
| 149 | |
| 150 | const { result, waitForNextUpdate } = renderHook( () => useMediaList() ); |
| 151 | await waitForNextUpdate(); |
| 152 | |
| 153 | expect( result.current.error ).toBe( 'boom' ); |
| 154 | expect( result.current.items ).toEqual( [] ); |
| 155 | expect( result.current.loading ).toBe( false ); |
| 156 | } ); |
| 157 | |
| 158 | it( 'refetch() reissues the same request', async () => { |
| 159 | apiFetch.mockResolvedValue( |
| 160 | mockResponse( { items: [ buildItem( 1, 'a' ) ] } ) |
| 161 | ); |
| 162 | |
| 163 | const { result, waitForNextUpdate } = renderHook( () => useMediaList() ); |
| 164 | await waitForNextUpdate(); |
| 165 | |
| 166 | expect( apiFetch ).toHaveBeenCalledTimes( 1 ); |
| 167 | |
| 168 | act( () => { |
| 169 | result.current.refetch(); |
| 170 | } ); |
| 171 | await waitForNextUpdate(); |
| 172 | |
| 173 | expect( apiFetch ).toHaveBeenCalledTimes( 2 ); |
| 174 | } ); |
| 175 | |
| 176 | it( 'decodes HTML entities in titles at the boundary', async () => { |
| 177 | apiFetch.mockResolvedValue( |
| 178 | mockResponse( { items: [ buildItem( 1, 'John's & Co' ) ] } ) |
| 179 | ); |
| 180 | |
| 181 | const { result, waitForNextUpdate } = renderHook( () => useMediaList() ); |
| 182 | await waitForNextUpdate(); |
| 183 | |
| 184 | expect( result.current.items[ 0 ].title ).toBe( "John's & Co" ); |
| 185 | } ); |
| 186 | |
| 187 | it( 'exposes counts and allTags from the envelope', async () => { |
| 188 | apiFetch.mockResolvedValue( |
| 189 | mockResponse( { |
| 190 | items: [], |
| 191 | counts: { |
| 192 | publish: 12, |
| 193 | draft: 3, |
| 194 | pending: 0, |
| 195 | private: 1, |
| 196 | future: 0, |
| 197 | trash: 5, |
| 198 | }, |
| 199 | allTags: [ { id: 7, name: 'Marketing', slug: 'marketing' } ], |
| 200 | } ) |
| 201 | ); |
| 202 | |
| 203 | const { result, waitForNextUpdate } = renderHook( () => useMediaList() ); |
| 204 | await waitForNextUpdate(); |
| 205 | |
| 206 | expect( result.current.counts.publish ).toBe( 12 ); |
| 207 | expect( result.current.counts.trash ).toBe( 5 ); |
| 208 | expect( result.current.allTags ).toEqual( [ |
| 209 | { id: 7, name: 'Marketing', slug: 'marketing' }, |
| 210 | ] ); |
| 211 | } ); |
| 212 | |
| 213 | // F-2: apiFetch({ parse: false }) rejects with the raw Response on 4xx/5xx. |
| 214 | // The hook must extract the error envelope's `message` from the rejected |
| 215 | // Response and surface it via `error` instead of silently rendering an |
| 216 | // empty list. |
| 217 | it( 'surfaces HTTP error body message as error state', async () => { |
| 218 | apiFetch.mockRejectedValueOnce( |
| 219 | mockHttpError( { |
| 220 | status: 401, |
| 221 | code: 'rest_cookie_invalid_nonce', |
| 222 | message: 'Cookie nonce is invalid', |
| 223 | } ) |
| 224 | ); |
| 225 | |
| 226 | const { result, waitForNextUpdate } = renderHook( () => useMediaList() ); |
| 227 | await waitForNextUpdate(); |
| 228 | |
| 229 | expect( result.current.error ).toBe( 'Cookie nonce is invalid' ); |
| 230 | expect( result.current.items ).toEqual( [] ); |
| 231 | expect( result.current.loading ).toBe( false ); |
| 232 | } ); |
| 233 | |
| 234 | // F-2 fallback: when the rejected Response body has no `message`, fall |
| 235 | // back to "HTTP <status>" so the user still gets an actionable hint. |
| 236 | it( 'falls back to "HTTP <status>" when error body lacks a message', async () => { |
| 237 | apiFetch.mockRejectedValueOnce( { |
| 238 | status: 500, |
| 239 | headers: { get: () => null }, |
| 240 | json: async () => ( {} ), |
| 241 | } ); |
| 242 | |
| 243 | const { result, waitForNextUpdate } = renderHook( () => useMediaList() ); |
| 244 | await waitForNextUpdate(); |
| 245 | |
| 246 | expect( result.current.error ).toBe( 'HTTP 500' ); |
| 247 | } ); |
| 248 | |
| 249 | // F-6: `hasLoadedOnce` is false before the first response settles and |
| 250 | // true thereafter (success OR error). Consumers gate their initial-load |
| 251 | // skeleton on this flag. |
| 252 | it( 'tracks hasLoadedOnce across success and error settles', async () => { |
| 253 | apiFetch.mockResolvedValue( |
| 254 | mockResponse( { items: [ buildItem( 1, 'a' ) ] } ) |
| 255 | ); |
| 256 | |
| 257 | const { result, waitForNextUpdate } = renderHook( () => useMediaList() ); |
| 258 | |
| 259 | // Hook initialises with hasLoadedOnce=false during the first fetch. |
| 260 | expect( result.current.hasLoadedOnce ).toBe( false ); |
| 261 | await waitForNextUpdate(); |
| 262 | expect( result.current.hasLoadedOnce ).toBe( true ); |
| 263 | |
| 264 | // Stays true even after a subsequent error. |
| 265 | apiFetch.mockRejectedValueOnce( new Error( 'boom' ) ); |
| 266 | act( () => { |
| 267 | result.current.refetch(); |
| 268 | } ); |
| 269 | await waitForNextUpdate(); |
| 270 | expect( result.current.hasLoadedOnce ).toBe( true ); |
| 271 | expect( result.current.error ).toBe( 'boom' ); |
| 272 | } ); |
| 273 | |
| 274 | // F-12: an untitled draft renders as `Media #<id>` instead of a blank |
| 275 | // string so the row remains identifiable in the dashboard. |
| 276 | it( 'falls back to "Media #<id>" when title is empty', async () => { |
| 277 | apiFetch.mockResolvedValue( |
| 278 | mockResponse( { items: [ buildItem( 42, '' ) ] } ) |
| 279 | ); |
| 280 | |
| 281 | const { result, waitForNextUpdate } = renderHook( () => useMediaList() ); |
| 282 | await waitForNextUpdate(); |
| 283 | |
| 284 | expect( result.current.items[ 0 ].title ).toBe( 'Media #42' ); |
| 285 | } ); |
| 286 | |
| 287 | // F-12: whitespace-only titles also fall back so we don't render |
| 288 | // invisible link text. |
| 289 | it( 'treats whitespace-only titles as empty', async () => { |
| 290 | apiFetch.mockResolvedValue( |
| 291 | mockResponse( { items: [ buildItem( 7, ' ' ) ] } ) |
| 292 | ); |
| 293 | |
| 294 | const { result, waitForNextUpdate } = renderHook( () => useMediaList() ); |
| 295 | await waitForNextUpdate(); |
| 296 | |
| 297 | expect( result.current.items[ 0 ].title ).toBe( 'Media #7' ); |
| 298 | } ); |
| 299 | } ); |
| 300 |