PluginProbe ʕ •ᴥ•ʔ
Presto Player / 4.3.3
Presto Player v4.3.3
4.4.0 4.3.3 4.3.2 4.3.1 4.3.0 4.2.4 4.2.3 4.2.2 4.2.0 4.2.1 trunk 1.10.0 1.10.1 1.10.2 1.11.0 1.12.0 1.13.0 1.14.0 1.14.1 1.5.10 1.5.11 1.5.12 1.5.13 1.5.14 1.5.15 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.6.10 1.6.11 1.6.12 1.6.13 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.7 1.6.8 1.6.9 1.7.0 1.7.1 1.7.2 1.8.0 1.8.1 1.8.2 1.8.3 1.8.4 1.8.5 1.8.6 1.9.0 1.9.1 1.9.10 1.9.11 1.9.12 1.9.13 1.9.14 1.9.2 1.9.3 1.9.4 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.0.1 2.0.10 2.0.11 2.0.12 2.0.13 2.0.14 2.0.15 2.0.16 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7 2.0.8 2.0.9 2.1.0 2.2.0 2.2.1 2.2.2 2.2.3 2.2.3-beta1 2.3.0 2.3.1 2.3.2 2.3.3 3.0.0 3.0.0-beta1 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.1.0 3.1.1 3.1.2 3.1.3 4.0.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.1.0 4.1.1 4.1.2 4.1.3 4.1.4
presto-player / src / admin / dashboard / hooks / test / useMediaList.spec.js
presto-player / src / admin / dashboard / hooks / test Last commit date
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&#039;s &amp; 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