Skip to content

Commit 46b524f

Browse files
committed
Add tests
1 parent 84f8b73 commit 46b524f

File tree

2 files changed

+687
-0
lines changed

2 files changed

+687
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,380 @@
1+
import '@testing-library/jest-dom';
2+
import { useConnectionErrorNotice, useRestoreConnection } from '@automattic/jetpack-connection';
3+
import { renderHook, waitFor } from '@testing-library/react';
4+
import React from 'react';
5+
import { NoticeContext } from '../../../context/notices/noticeContext';
6+
import useAnalytics from '../../use-analytics';
7+
import useConnectionErrorsNotice from '../use-connection-errors-notice';
8+
import type { NoticeContextType } from '../../../context/notices/types';
9+
10+
// Mock the dependencies
11+
jest.mock( '@automattic/jetpack-connection' );
12+
jest.mock( '../../use-analytics' );
13+
jest.mock( '@automattic/jetpack-components', () => ( {
14+
Col: ( { children }: { children: React.ReactNode } ) => <div>{ children }</div>,
15+
Text: ( { children }: { children: React.ReactNode } ) => <span>{ children }</span>,
16+
} ) );
17+
jest.mock( '@wordpress/i18n', () => ( {
18+
__: ( text: string ) => text,
19+
sprintf: ( text: string, ...args: string[] ) => {
20+
return text.replace( /%s/g, () => args.shift() );
21+
},
22+
isRTL: () => false,
23+
_x: ( text: string ) => text,
24+
_n: ( single: string, plural: string, number: number ) => ( number === 1 ? single : plural ),
25+
} ) );
26+
27+
// Mock window object
28+
Object.defineProperty( window, 'location', {
29+
value: {
30+
href: '',
31+
},
32+
writable: true,
33+
} );
34+
35+
// Mock Initial_State
36+
Object.defineProperty( window, 'Initial_State', {
37+
value: {
38+
adminUrl: '/wp-admin/',
39+
},
40+
writable: true,
41+
} );
42+
43+
const mockUseConnectionErrorNotice = useConnectionErrorNotice as jest.MockedFunction<
44+
typeof useConnectionErrorNotice
45+
>;
46+
const mockUseRestoreConnection = useRestoreConnection as jest.MockedFunction<
47+
typeof useRestoreConnection
48+
>;
49+
const mockUseAnalytics = useAnalytics as jest.MockedFunction< typeof useAnalytics >;
50+
51+
describe( 'useConnectionErrorsNotice', () => {
52+
const mockSetNotice = jest.fn();
53+
const mockRecordEvent = jest.fn();
54+
const mockRestoreConnection = jest.fn();
55+
56+
const mockNoticeContext: NoticeContextType = {
57+
setNotice: mockSetNotice,
58+
resetNotice: jest.fn(),
59+
currentNotice: {
60+
message: '',
61+
title: '',
62+
options: {
63+
id: '',
64+
level: 'info',
65+
actions: [],
66+
priority: 0,
67+
},
68+
},
69+
};
70+
71+
const defaultConnectionError = {
72+
hasConnectionError: false,
73+
connectionErrorMessage: '',
74+
};
75+
76+
const defaultRestoreConnection = {
77+
restoreConnection: mockRestoreConnection,
78+
isRestoringConnection: false,
79+
restoreConnectionError: null,
80+
};
81+
82+
beforeEach( () => {
83+
jest.clearAllMocks();
84+
85+
mockUseConnectionErrorNotice.mockReturnValue( defaultConnectionError );
86+
mockUseRestoreConnection.mockReturnValue( defaultRestoreConnection );
87+
mockUseAnalytics.mockReturnValue( { recordEvent: mockRecordEvent } );
88+
} );
89+
90+
const renderWithNoticeContext = ( contextValue = mockNoticeContext ) => {
91+
const wrapper = ( { children }: { children: React.ReactNode } ) => (
92+
<NoticeContext.Provider value={ contextValue }>{ children }</NoticeContext.Provider>
93+
);
94+
95+
return renderHook( () => useConnectionErrorsNotice(), { wrapper } );
96+
};
97+
98+
describe( 'when there are no connection errors', () => {
99+
it( 'should not set any notice', () => {
100+
renderWithNoticeContext();
101+
expect( mockSetNotice ).not.toHaveBeenCalled();
102+
} );
103+
} );
104+
105+
describe( 'when there is a standard connection error', () => {
106+
beforeEach( () => {
107+
mockUseConnectionErrorNotice.mockReturnValue( {
108+
hasConnectionError: true,
109+
connectionErrorMessage: 'Connection failed due to network issue',
110+
} );
111+
} );
112+
113+
it( 'should set a notice with restore connection action', async () => {
114+
renderWithNoticeContext();
115+
116+
await waitFor( () => {
117+
expect( mockSetNotice ).toHaveBeenCalledWith( {
118+
message: 'Connection failed due to network issue',
119+
options: {
120+
id: 'connection-error-notice',
121+
level: 'error',
122+
actions: [
123+
{
124+
label: 'Restore Connection',
125+
onClick: expect.any( Function ),
126+
isLoading: false,
127+
loadingText: 'Reconnecting Jetpack…',
128+
noDefaultClasses: true,
129+
},
130+
],
131+
priority: 300, // NOTICE_PRIORITY_HIGH + 0
132+
},
133+
} );
134+
} );
135+
} );
136+
137+
it( 'should call restoreConnection and record analytics when restore button is clicked', async () => {
138+
renderWithNoticeContext();
139+
140+
await waitFor( () => {
141+
expect( mockSetNotice ).toHaveBeenCalled();
142+
} );
143+
144+
const setNoticeCall = mockSetNotice.mock.calls[ 0 ][ 0 ];
145+
const restoreAction = setNoticeCall.options.actions[ 0 ];
146+
147+
// Simulate clicking the restore button
148+
restoreAction.onClick();
149+
150+
expect( mockRestoreConnection ).toHaveBeenCalled();
151+
expect( mockRecordEvent ).toHaveBeenCalledWith(
152+
'jetpack_my_jetpack_connection_error_notice_reconnect_cta_click'
153+
);
154+
} );
155+
156+
it( 'should show loading state when restoring connection', async () => {
157+
mockUseRestoreConnection.mockReturnValue( {
158+
...defaultRestoreConnection,
159+
isRestoringConnection: true,
160+
} );
161+
162+
renderWithNoticeContext();
163+
164+
await waitFor( () => {
165+
expect( mockSetNotice ).toHaveBeenCalledWith(
166+
expect.objectContaining( {
167+
options: expect.objectContaining( {
168+
actions: [
169+
expect.objectContaining( {
170+
isLoading: true,
171+
loadingText: 'Reconnecting Jetpack…',
172+
} ),
173+
],
174+
priority: 301, // NOTICE_PRIORITY_HIGH + 1
175+
} ),
176+
} )
177+
);
178+
} );
179+
} );
180+
181+
it( 'should show restore connection error in message', async () => {
182+
mockUseRestoreConnection.mockReturnValue( {
183+
...defaultRestoreConnection,
184+
restoreConnectionError: 'Failed to restore connection',
185+
} );
186+
187+
renderWithNoticeContext();
188+
189+
await waitFor( () => {
190+
expect( mockSetNotice ).toHaveBeenCalledWith(
191+
expect.objectContaining( {
192+
message: expect.anything(), // Should be a React element with both messages
193+
} )
194+
);
195+
} );
196+
} );
197+
} );
198+
199+
describe( 'when there is a protected owner error', () => {
200+
const protectedOwnerErrorCases = [
201+
{
202+
description: 'plan owner',
203+
message: 'The WordPress.com plan owner is missing',
204+
},
205+
{
206+
description: 'WordPress.com plan owner',
207+
message: 'The WordPress.com plan owner needs to be connected',
208+
},
209+
{
210+
description: 'protected owner',
211+
message: 'This site has a protected owner issue',
212+
},
213+
];
214+
215+
protectedOwnerErrorCases.forEach( ( { description, message } ) => {
216+
describe( `containing "${ description }"`, () => {
217+
beforeEach( () => {
218+
mockUseConnectionErrorNotice.mockReturnValue( {
219+
hasConnectionError: true,
220+
connectionErrorMessage: message,
221+
} );
222+
} );
223+
224+
it( 'should set a notice with create missing account action', async () => {
225+
renderWithNoticeContext();
226+
227+
await waitFor( () => {
228+
expect( mockSetNotice ).toHaveBeenCalledWith( {
229+
message,
230+
options: {
231+
id: 'connection-error-notice',
232+
level: 'error',
233+
actions: [
234+
{
235+
label: 'Create missing account',
236+
onClick: expect.any( Function ),
237+
noDefaultClasses: true,
238+
variant: 'primary',
239+
},
240+
],
241+
priority: 300, // NOTICE_PRIORITY_HIGH + 0
242+
},
243+
} );
244+
} );
245+
} );
246+
247+
it( 'should record analytics and redirect when create missing account is clicked', async () => {
248+
renderWithNoticeContext();
249+
250+
await waitFor( () => {
251+
expect( mockSetNotice ).toHaveBeenCalled();
252+
} );
253+
254+
const setNoticeCall = mockSetNotice.mock.calls[ 0 ][ 0 ];
255+
const createAccountAction = setNoticeCall.options.actions[ 0 ];
256+
257+
// Simulate clicking the create missing account button
258+
createAccountAction.onClick();
259+
260+
expect( mockRecordEvent ).toHaveBeenCalledWith(
261+
'jetpack_my_jetpack_protected_owner_create_account_attempt',
262+
{}
263+
);
264+
265+
expect( window.location.href ).toBe( '/wp-admin/user-new.php' );
266+
} );
267+
} );
268+
} );
269+
270+
it( 'should use custom adminUrl when available', async () => {
271+
window.Initial_State = { adminUrl: '/custom-admin/' };
272+
273+
mockUseConnectionErrorNotice.mockReturnValue( {
274+
hasConnectionError: true,
275+
connectionErrorMessage: 'The WordPress.com plan owner is missing',
276+
} );
277+
278+
renderWithNoticeContext();
279+
280+
await waitFor( () => {
281+
expect( mockSetNotice ).toHaveBeenCalled();
282+
} );
283+
284+
const setNoticeCall = mockSetNotice.mock.calls[ 0 ][ 0 ];
285+
const createAccountAction = setNoticeCall.options.actions[ 0 ];
286+
287+
createAccountAction.onClick();
288+
289+
expect( window.location.href ).toBe( '/custom-admin/user-new.php' );
290+
} );
291+
292+
it( 'should fallback to default admin path when Initial_State is undefined', async () => {
293+
window.Initial_State = undefined;
294+
295+
mockUseConnectionErrorNotice.mockReturnValue( {
296+
hasConnectionError: true,
297+
connectionErrorMessage: 'The WordPress.com plan owner is missing',
298+
} );
299+
300+
renderWithNoticeContext();
301+
302+
await waitFor( () => {
303+
expect( mockSetNotice ).toHaveBeenCalled();
304+
} );
305+
306+
const setNoticeCall = mockSetNotice.mock.calls[ 0 ][ 0 ];
307+
const createAccountAction = setNoticeCall.options.actions[ 0 ];
308+
309+
createAccountAction.onClick();
310+
311+
expect( window.location.href ).toBe( '/wp-admin/user-new.php' );
312+
} );
313+
} );
314+
315+
describe( 'notice priority calculation', () => {
316+
it( 'should use higher priority when restoring connection', async () => {
317+
mockUseConnectionErrorNotice.mockReturnValue( {
318+
hasConnectionError: true,
319+
connectionErrorMessage: 'Connection error',
320+
} );
321+
322+
mockUseRestoreConnection.mockReturnValue( {
323+
...defaultRestoreConnection,
324+
isRestoringConnection: true,
325+
} );
326+
327+
renderWithNoticeContext();
328+
329+
await waitFor( () => {
330+
expect( mockSetNotice ).toHaveBeenCalledWith(
331+
expect.objectContaining( {
332+
options: expect.objectContaining( {
333+
priority: 301, // NOTICE_PRIORITY_HIGH + 1
334+
} ),
335+
} )
336+
);
337+
} );
338+
} );
339+
340+
it( 'should use base priority when not restoring connection', async () => {
341+
mockUseConnectionErrorNotice.mockReturnValue( {
342+
hasConnectionError: true,
343+
connectionErrorMessage: 'Connection error',
344+
} );
345+
346+
renderWithNoticeContext();
347+
348+
await waitFor( () => {
349+
expect( mockSetNotice ).toHaveBeenCalledWith(
350+
expect.objectContaining( {
351+
options: expect.objectContaining( {
352+
priority: 300, // NOTICE_PRIORITY_HIGH + 0
353+
} ),
354+
} )
355+
);
356+
} );
357+
} );
358+
} );
359+
360+
describe( 'dependency array handling', () => {
361+
it( 'should re-run effect when dependencies change', async () => {
362+
const { rerender } = renderWithNoticeContext();
363+
364+
// Initially no error
365+
expect( mockSetNotice ).not.toHaveBeenCalled();
366+
367+
// Add an error
368+
mockUseConnectionErrorNotice.mockReturnValue( {
369+
hasConnectionError: true,
370+
connectionErrorMessage: 'New connection error',
371+
} );
372+
373+
rerender();
374+
375+
await waitFor( () => {
376+
expect( mockSetNotice ).toHaveBeenCalled();
377+
} );
378+
} );
379+
} );
380+
} );

0 commit comments

Comments
 (0)