PluginProbe
Extendify / 3.1.1
Extendify v3.1.1
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
extendify / tests / unit / QuickEdit / components / modals / UnsplashImagePickerModal.test.jsx

UnsplashImagePickerModal.test.jsx in Extendify 3.1.1, at tests/unit/QuickEdit/components/modals/UnsplashImagePickerModal.test.jsx

461 lines 12.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 // UnsplashImagePickerModal seeds its first fetch from the site profile's first
2 // imageSearchTerm (not from the shared Unsplash cache — see the comment in the
3 // source), debounces typed searches, then on click downloads the picked image
4 // into the media library and either (a) save() + splice for post sources or
5 // (b) saveProduct + reload for product sources. Tests pin both branches plus
6 // the load + download sad paths.
7
8 import { act, fireEvent, render, waitFor } from '@testing-library/react';
9
10 const mockFetchImages = jest.fn();
11 const mockDownloadImage = jest.fn();
12 const mockSave = jest.fn();
13 const mockSaveProduct = jest.fn();
14 const mockLoadProduct = jest.fn();
15 const mockSplice = jest.fn();
16 const mockInvalidate = jest.fn();
17 const mockTrack = jest.fn();
18 const mockPushUndo = jest.fn();
19 const mockModalProps = jest.fn();
20
21 jest.mock('@shared/api/wp', () => ({
22 downloadImage: (...args) => mockDownloadImage(...args),
23 }));
24 jest.mock('@shared/lib/unsplash', () => ({
25 fetchImages: (...args) => mockFetchImages(...args),
26 }));
27 jest.mock('@quick-edit/lib/api', () => ({
28 loadProduct: (...args) => mockLoadProduct(...args),
29 save: (...args) => mockSave(...args),
30 saveProduct: (...args) => mockSaveProduct(...args),
31 }));
32 jest.mock('@quick-edit/lib/block-source-cache', () => ({
33 invalidateBlockSource: (...args) => mockInvalidate(...args),
34 }));
35 jest.mock('@quick-edit/lib/dom', () => ({
36 splice: (...args) => mockSplice(...args),
37 }));
38 jest.mock('@quick-edit/lib/insights', () => ({
39 track: (...args) => mockTrack(...args),
40 }));
41 jest.mock('@quick-edit/state/undo', () => ({
42 pushUndo: (...args) => mockPushUndo(...args),
43 }));
44
45 jest.mock('@wordpress/components', () => ({
46 Modal: (props) => {
47 mockModalProps(props);
48 const { title, onRequestClose, children } = props;
49 return (
50 <div role="dialog" aria-label={title}>
51 <button
52 type="button"
53 data-testid="modal-close"
54 onClick={onRequestClose}
55 />
56 {children}
57 </div>
58 );
59 },
60 Button: ({ children, onClick, disabled, variant }) => (
61 <button
62 type="button"
63 onClick={onClick}
64 disabled={disabled}
65 data-variant={variant}
66 >
67 {children}
68 </button>
69 ),
70 Notice: ({ children, status }) => (
71 <div role="alert" data-status={status}>
72 {children}
73 </div>
74 ),
75 Spinner: () => <span data-testid="spinner" />,
76 SearchControl: ({ value, onChange, placeholder, disabled }) => (
77 <input
78 type="search"
79 aria-label="Search Unsplash"
80 placeholder={placeholder}
81 value={value || ''}
82 onChange={(e) => onChange(e.target.value)}
83 disabled={disabled}
84 />
85 ),
86 }));
87
88 const liveImageEl = () => {
89 const wrap = document.createElement('figure');
90 const img = document.createElement('img');
91 img.src = 'https://example.test/before.jpg';
92 img.alt = 'before';
93 img.className = 'wp-image-77';
94 wrap.appendChild(img);
95 document.body.appendChild(wrap);
96 return wrap;
97 };
98
99 const baseSelected = () => ({
100 el: liveImageEl(),
101 blockId: 'b-img',
102 blockType: 'core/image',
103 source: { kind: 'post', id: 42 },
104 });
105
106 const sampleImage = (id) => ({
107 id,
108 urls: {
109 small: `https://images.unsplash.test/${id}-s.jpg`,
110 regular: `https://images.unsplash.test/${id}-r.jpg`,
111 },
112 alt_description: `alt-${id}`,
113 requestMetadata: { id: `req-${id}` },
114 user: {
115 name: 'Photographer Name',
116 links: { html: 'https://unsplash.test/u' },
117 },
118 });
119
120 const importComponent = () =>
121 require('@quick-edit/components/modals/UnsplashImagePickerModal');
122
123 const flush = () =>
124 act(async () => {
125 await Promise.resolve();
126 });
127
128 beforeEach(() => {
129 jest.clearAllMocks();
130 jest.useFakeTimers();
131 document.body.innerHTML = '';
132 window.extSharedData = {
133 ...(window.extSharedData || {}),
134 siteProfile: { imageSearchTerms: ['mountain lakes'] },
135 };
136 });
137
138 afterEach(() => {
139 jest.useRealTimers();
140 });
141
142 describe('UnsplashImagePickerModal — body-open class', () => {
143 it("overrides bodyOpenClassName so WP Modal doesn't toggle body.modal-open on mount", async () => {
144 mockFetchImages.mockResolvedValueOnce([]);
145 const { UnsplashImagePickerModal } = importComponent();
146 render(
147 <UnsplashImagePickerModal
148 selected={baseSelected()}
149 field="image"
150 onAfterSave={jest.fn()}
151 />,
152 );
153 await flush();
154 expect(mockModalProps).toHaveBeenCalledWith(
155 expect.objectContaining({
156 bodyOpenClassName: 'extendify-quick-edit-modal-open',
157 }),
158 );
159 });
160 });
161
162 describe('UnsplashImagePickerModal — initial fetch', () => {
163 it('seeds the query from window.extSharedData.siteProfile.imageSearchTerms[0]', async () => {
164 mockFetchImages.mockResolvedValueOnce([sampleImage('a')]);
165 const { UnsplashImagePickerModal } = importComponent();
166 render(
167 <UnsplashImagePickerModal
168 selected={baseSelected()}
169 field="image"
170 onAfterSave={jest.fn()}
171 />,
172 );
173 await flush();
174 expect(mockFetchImages).toHaveBeenCalledWith('mountain lakes', 'user');
175 });
176
177 it('falls back to "unsplash" when no seed is configured', async () => {
178 window.extSharedData.siteProfile = {};
179 mockFetchImages.mockResolvedValueOnce([]);
180 const { UnsplashImagePickerModal } = importComponent();
181 render(
182 <UnsplashImagePickerModal
183 selected={baseSelected()}
184 field="image"
185 onAfterSave={jest.fn()}
186 />,
187 );
188 await flush();
189 expect(mockFetchImages).toHaveBeenCalledWith('unsplash', 'user');
190 });
191
192 it('renders a Notice when the load rejects', async () => {
193 mockFetchImages.mockRejectedValueOnce(new Error('upstream is down'));
194 const { UnsplashImagePickerModal } = importComponent();
195 render(
196 <UnsplashImagePickerModal
197 selected={baseSelected()}
198 field="image"
199 onAfterSave={jest.fn()}
200 />,
201 );
202 await flush();
203 expect(document.querySelector('[role="alert"]')?.textContent).toMatch(
204 /Sorry, something went wrong/i,
205 );
206 });
207 });
208
209 describe('UnsplashImagePickerModal — search debounce', () => {
210 it('debounces 500ms before refetching with the typed query + emits track', async () => {
211 mockFetchImages.mockResolvedValue([]);
212 const { UnsplashImagePickerModal } = importComponent();
213 render(
214 <UnsplashImagePickerModal
215 selected={baseSelected()}
216 field="image"
217 onAfterSave={jest.fn()}
218 />,
219 );
220 await flush();
221 mockFetchImages.mockClear();
222 const input = document.querySelector('input[type="search"]');
223 fireEvent.change(input, { target: { value: 'sunset' } });
224 expect(mockFetchImages).not.toHaveBeenCalled();
225 await act(async () => {
226 jest.advanceTimersByTime(500);
227 await Promise.resolve();
228 });
229 expect(mockFetchImages).toHaveBeenCalledWith('sunset', 'user');
230 expect(mockTrack).toHaveBeenCalledWith('unsplash_searched', { len: 6 });
231 });
232 });
233
234 describe('UnsplashImagePickerModal — pick (post-context happy path)', () => {
235 beforeEach(() => {
236 mockFetchImages.mockResolvedValue([sampleImage('p1')]);
237 mockDownloadImage.mockResolvedValue({
238 id: 909,
239 url: 'https://wp.test/wp-content/uploads/p1.jpg',
240 alt_text: 'a-mountain',
241 });
242 mockSave.mockResolvedValue({ rendered: '<figure>new</figure>' });
243 mockSplice.mockReturnValue(document.createElement('figure'));
244 });
245
246 const renderAndPick = async (selected) => {
247 const onAfterSave = jest.fn();
248 const { UnsplashImagePickerModal } = importComponent();
249 render(
250 <UnsplashImagePickerModal
251 selected={selected}
252 field="image"
253 onAfterSave={onAfterSave}
254 />,
255 );
256 await flush();
257 const tile = document.querySelector(
258 '.extendify-quick-edit-image-grid-item',
259 );
260 fireEvent.click(tile);
261 await flush();
262 return onAfterSave;
263 };
264
265 it('calls downloadImage with the unsplash metadata + image-context props', async () => {
266 await renderAndPick(baseSelected());
267 expect(mockDownloadImage).toHaveBeenCalledWith(
268 'req-p1',
269 'https://images.unsplash.test/p1-r.jpg',
270 'unsplash',
271 'p1',
272 { alt: 'alt-p1', caption: '' },
273 );
274 });
275
276 it('calls save() with the patches envelope carrying the downloaded media', async () => {
277 await renderAndPick(baseSelected());
278 await waitFor(() => expect(mockSave).toHaveBeenCalled());
279 expect(mockSave).toHaveBeenCalledWith({
280 source: { kind: 'post', id: 42 },
281 blockId: 'b-img',
282 blockType: 'core/image',
283 patches: [
284 {
285 fieldKey: 'image',
286 value: {
287 url: 'https://wp.test/wp-content/uploads/p1.jpg',
288 id: 909,
289 alt: 'a-mountain',
290 },
291 },
292 ],
293 });
294 });
295
296 it('splices, invalidates cache, pushes the before-image undo, tracks, and resolves true', async () => {
297 const sel = baseSelected();
298 const onAfterSave = await renderAndPick(sel);
299 await waitFor(() => expect(mockSplice).toHaveBeenCalled());
300 expect(mockSplice).toHaveBeenCalledWith(sel.el, '<figure>new</figure>');
301 expect(mockInvalidate).toHaveBeenCalledWith(
302 { kind: 'post', id: 42 },
303 'b-img',
304 );
305 expect(mockPushUndo).toHaveBeenCalledWith({
306 kind: 'image',
307 source: { kind: 'post', id: 42 },
308 blockId: 'b-img',
309 blockType: 'core/image',
310 patches: [
311 {
312 fieldKey: 'image',
313 value: {
314 url: 'https://example.test/before.jpg',
315 id: 77,
316 alt: 'before',
317 },
318 },
319 ],
320 });
321 expect(mockTrack).toHaveBeenCalledWith('image_replaced', {
322 source: 'unsplash',
323 });
324 await waitFor(() => expect(onAfterSave).toHaveBeenCalledWith(true));
325 });
326 });
327
328 describe('UnsplashImagePickerModal — pick (product-context branch)', () => {
329 it('saveProduct + reload; never touches save/splice/invalidate', async () => {
330 mockFetchImages.mockResolvedValue([sampleImage('p1')]);
331 mockDownloadImage.mockResolvedValue({
332 id: 909,
333 url: 'https://wp.test/p1.jpg',
334 alt_text: '',
335 });
336 mockLoadProduct.mockResolvedValue({ image_id: 11 });
337 mockSaveProduct.mockResolvedValue({});
338
339 const onAfterSave = jest.fn();
340 const { UnsplashImagePickerModal } = importComponent();
341 render(
342 <UnsplashImagePickerModal
343 selected={{
344 ...baseSelected(),
345 source: { kind: 'product', id: 55 },
346 }}
347 field="image"
348 onAfterSave={onAfterSave}
349 />,
350 );
351 await flush();
352 const tile = document.querySelector(
353 '.extendify-quick-edit-image-grid-item',
354 );
355 fireEvent.click(tile);
356 await waitFor(() =>
357 expect(mockSaveProduct).toHaveBeenCalledWith({
358 productId: 55,
359 field: 'image',
360 value: 909,
361 }),
362 );
363 expect(mockSave).not.toHaveBeenCalled();
364 expect(mockSplice).not.toHaveBeenCalled();
365 expect(mockInvalidate).not.toHaveBeenCalled();
366 expect(mockPushUndo).toHaveBeenCalledWith({
367 kind: 'product-image',
368 productReplay: true,
369 productId: 55,
370 field: 'image',
371 beforeValue: 11,
372 });
373 expect(mockTrack).toHaveBeenCalledWith('image_replaced', {
374 source: 'unsplash',
375 kind: 'product',
376 });
377 await waitFor(() => expect(onAfterSave).toHaveBeenCalledWith(true));
378 });
379 });
380
381 describe('UnsplashImagePickerModal — sad path + close', () => {
382 it('downloadImage rejecting tracks save_failed + surfaces the message', async () => {
383 mockFetchImages.mockResolvedValue([sampleImage('p1')]);
384 mockDownloadImage.mockRejectedValueOnce(new Error('quota exceeded'));
385 const { UnsplashImagePickerModal } = importComponent();
386 render(
387 <UnsplashImagePickerModal
388 selected={baseSelected()}
389 field="image"
390 onAfterSave={jest.fn()}
391 />,
392 );
393 await flush();
394 fireEvent.click(
395 document.querySelector('.extendify-quick-edit-image-grid-item'),
396 );
397 await waitFor(() =>
398 expect(mockTrack).toHaveBeenCalledWith('save_failed', {
399 kind: 'image',
400 source: 'unsplash',
401 }),
402 );
403 expect(document.querySelector('[role="alert"]')?.textContent).toMatch(
404 /Sorry, something went wrong/i,
405 );
406 });
407
408 it('Cancel button calls onAfterSave(false)', async () => {
409 mockFetchImages.mockResolvedValue([]);
410 const onAfterSave = jest.fn();
411 const { UnsplashImagePickerModal } = importComponent();
412 render(
413 <UnsplashImagePickerModal
414 selected={baseSelected()}
415 field="image"
416 onAfterSave={onAfterSave}
417 />,
418 );
419 await flush();
420 const cancel = Array.from(document.querySelectorAll('button')).find(
421 (b) => b.textContent === 'Cancel',
422 );
423 fireEvent.click(cancel);
424 expect(onAfterSave).toHaveBeenCalledWith(false);
425 });
426 });
427
428 // The grid's transient states announce
429 // politely (errors already speak assertively via WP Notice).
430 describe('UnsplashImagePickerModal — a11y status regions', () => {
431 it('exposes a role="status" loading region while images load', () => {
432 mockFetchImages.mockReturnValueOnce(new Promise(() => {}));
433 const { UnsplashImagePickerModal } = importComponent();
434 render(
435 <UnsplashImagePickerModal
436 selected={baseSelected()}
437 field="image"
438 onAfterSave={jest.fn()}
439 />,
440 );
441 const status = document.querySelector('[role="status"]');
442 expect(status).not.toBeNull();
443 expect(status.querySelector('[data-testid="spinner"]')).not.toBeNull();
444 });
445
446 it('exposes the empty state as a role="status" region', async () => {
447 mockFetchImages.mockResolvedValueOnce([]);
448 const { UnsplashImagePickerModal } = importComponent();
449 render(
450 <UnsplashImagePickerModal
451 selected={baseSelected()}
452 field="image"
453 onAfterSave={jest.fn()}
454 />,
455 );
456 await flush();
457 const empty = document.querySelector('[role="status"]');
458 expect(empty?.textContent).toBe('No images found.');
459 });
460 });
461