newspack-components 4.2.0 → 4.4.0-epic-editor-refactor.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (315) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/DEVELOPMENT.md +868 -0
  3. package/copy-styles.js +3 -2
  4. package/dist/cjs/accordion/index.js +17 -12
  5. package/dist/cjs/action-card/index.js +186 -140
  6. package/dist/cjs/action-card/style.scss +25 -2
  7. package/dist/cjs/autocomplete-tokenfield/index.js +254 -234
  8. package/dist/cjs/autocomplete-with-latest-posts/index.js +351 -0
  9. package/dist/cjs/autocomplete-with-latest-posts/style.scss +150 -0
  10. package/dist/cjs/autocomplete-with-suggestions/index.js +229 -126
  11. package/dist/cjs/badge/index.js +32 -0
  12. package/dist/cjs/badge/style.scss +36 -0
  13. package/dist/cjs/box-contrast/index.js +19 -15
  14. package/dist/cjs/button/index.js +41 -24
  15. package/dist/cjs/button-props/button-props.js +5 -5
  16. package/dist/cjs/button-props/index.js +2 -2
  17. package/dist/cjs/card/core-card.js +199 -0
  18. package/dist/cjs/card/index.js +75 -35
  19. package/dist/cjs/card/style-core.scss +295 -0
  20. package/dist/cjs/card/style.scss +4 -4
  21. package/dist/cjs/card-feature/index.js +141 -0
  22. package/dist/cjs/card-feature/style.scss +75 -0
  23. package/dist/cjs/card-form/index.js +144 -0
  24. package/dist/cjs/card-form/style.scss +20 -0
  25. package/dist/cjs/card-settings-group/index.js +69 -0
  26. package/dist/cjs/card-settings-group/style.scss +33 -0
  27. package/dist/cjs/card-sortable-list/index.js +426 -0
  28. package/dist/cjs/card-sortable-list/style.scss +48 -0
  29. package/dist/cjs/category-autocomplete/index.js +175 -155
  30. package/dist/cjs/color-picker/index.js +41 -23
  31. package/dist/cjs/color-picker/index.test.js +82 -0
  32. package/dist/cjs/color-picker/style.scss +11 -13
  33. package/dist/cjs/confirm-dialog/index.js +123 -0
  34. package/dist/cjs/consts.js +1 -1
  35. package/dist/cjs/custom-select-control/index.js +39 -28
  36. package/dist/cjs/dataviews/index.js +38 -0
  37. package/dist/cjs/dataviews/style.scss +9 -0
  38. package/dist/cjs/divider/index.js +47 -0
  39. package/dist/cjs/divider/style.scss +51 -0
  40. package/dist/cjs/footer/index.js +36 -30
  41. package/dist/cjs/form-token-field/index.js +51 -40
  42. package/dist/cjs/form-token-field/style.scss +0 -5
  43. package/dist/cjs/global-notices/index.js +5 -5
  44. package/dist/cjs/grid/index.js +25 -18
  45. package/dist/cjs/grid/style.scss +67 -8
  46. package/dist/cjs/handoff/index.js +204 -156
  47. package/dist/cjs/handoff-message/index.js +12 -7
  48. package/dist/cjs/hooks/index.js +10 -8
  49. package/dist/cjs/hooks/use-confirm-dialog.js +79 -0
  50. package/dist/cjs/hooks/use-unsaved-changes-dialog.js +144 -0
  51. package/dist/cjs/hooks/useObjectState.js +21 -12
  52. package/dist/cjs/hooks/useObjectState.test.js +55 -33
  53. package/dist/cjs/hooks/useOnClickOutside.js +6 -6
  54. package/dist/cjs/hooks/usePrompt.js +10 -13
  55. package/dist/cjs/image-upload/index.js +114 -123
  56. package/dist/cjs/image-upload/index.test.js +11 -13
  57. package/dist/cjs/image-upload/style.scss +12 -40
  58. package/dist/cjs/index.js +166 -92
  59. package/dist/cjs/info-button/index.js +43 -32
  60. package/dist/cjs/integration-icons/active-campaign.js +22 -0
  61. package/dist/cjs/integration-icons/constant-contact.js +28 -0
  62. package/dist/cjs/integration-icons/fundraise-up.js +25 -0
  63. package/dist/cjs/integration-icons/index.js +48 -0
  64. package/dist/cjs/integration-icons/mailchimp.js +19 -0
  65. package/dist/cjs/integration-icons/salesforce.js +17 -0
  66. package/dist/cjs/integration-icons/wisepops.js +20 -0
  67. package/dist/cjs/modal/index.js +29 -19
  68. package/dist/cjs/modal/style.scss +47 -13
  69. package/dist/cjs/newspack-icon/index.js +45 -38
  70. package/dist/cjs/newspack-icon/style.scss +5 -3
  71. package/dist/cjs/notice/index.js +64 -55
  72. package/dist/cjs/plugin-installer/index.js +259 -253
  73. package/dist/cjs/plugin-settings/SettingsSection.js +66 -60
  74. package/dist/cjs/plugin-settings/index.js +217 -200
  75. package/dist/cjs/plugin-toggle/index.js +192 -199
  76. package/dist/cjs/popover/index.js +41 -28
  77. package/dist/cjs/position-control/index.js +24 -23
  78. package/dist/cjs/progress-bar/index.js +71 -59
  79. package/dist/cjs/progress-bar/index.test.js +62 -68
  80. package/dist/cjs/proxied-imports/router.js +3 -3
  81. package/dist/cjs/radio-control/index.js +40 -27
  82. package/dist/cjs/section-header/index.js +143 -39
  83. package/dist/cjs/section-header/style.scss +129 -4
  84. package/dist/cjs/select-control/ButtonGroupControl.js +26 -21
  85. package/dist/cjs/select-control/GroupedSelectControl.js +41 -35
  86. package/dist/cjs/select-control/index.js +52 -40
  87. package/dist/cjs/settings/MinMaxSetting.js +30 -20
  88. package/dist/cjs/settings/SettingsCard.js +20 -17
  89. package/dist/cjs/settings/SettingsSection.js +22 -21
  90. package/dist/cjs/settings/index.js +5 -5
  91. package/dist/cjs/sortable-newsletter-list-control/index.js +78 -57
  92. package/dist/cjs/steps-list/index.js +44 -34
  93. package/dist/cjs/steps-list-item/index.js +47 -39
  94. package/dist/cjs/style-card/index.js +70 -63
  95. package/dist/cjs/style.scss +2 -0
  96. package/dist/cjs/tabbed-navigation/index.js +38 -36
  97. package/dist/cjs/text-control/index.js +27 -25
  98. package/dist/cjs/utils/color.js +4 -4
  99. package/dist/cjs/utils/editor-toolbar-back-button.js +61 -0
  100. package/dist/cjs/utils/index.js +31 -19
  101. package/dist/cjs/utils/style.scss +8 -0
  102. package/dist/cjs/waiting/index.js +48 -35
  103. package/dist/cjs/waiting/style.scss +4 -0
  104. package/dist/cjs/web-preview/index.js +219 -154
  105. package/dist/cjs/web-preview/style.scss +20 -53
  106. package/dist/cjs/with-wizard/index.js +362 -364
  107. package/dist/cjs/with-wizard/style.scss +28 -60
  108. package/dist/cjs/with-wizard-screen/index.js +54 -58
  109. package/dist/cjs/with-wizard-screen/style.scss +77 -10
  110. package/dist/cjs/wizard/components/WizardError.js +20 -18
  111. package/dist/cjs/wizard/components/WizardSnackbar.js +73 -0
  112. package/dist/cjs/wizard/components/style.scss +48 -0
  113. package/dist/cjs/wizard/index.js +207 -64
  114. package/dist/cjs/wizard/store/index.js +191 -97
  115. package/dist/cjs/wizard/store/utils.js +13 -8
  116. package/dist/esm/accordion/index.js +14 -9
  117. package/dist/esm/action-card/index.js +178 -124
  118. package/dist/esm/action-card/style.scss +25 -2
  119. package/dist/esm/autocomplete-tokenfield/index.js +248 -222
  120. package/dist/esm/autocomplete-with-latest-posts/index.js +347 -0
  121. package/dist/esm/autocomplete-with-latest-posts/style.scss +150 -0
  122. package/dist/esm/autocomplete-with-suggestions/index.js +226 -119
  123. package/dist/esm/badge/index.js +24 -0
  124. package/dist/esm/badge/style.scss +36 -0
  125. package/dist/esm/box-contrast/index.js +15 -11
  126. package/dist/esm/button/index.js +39 -19
  127. package/dist/esm/button-props/button-props.js +1 -1
  128. package/dist/esm/card/core-card.js +195 -0
  129. package/dist/esm/card/index.js +70 -22
  130. package/dist/esm/card/style-core.scss +295 -0
  131. package/dist/esm/card/style.scss +4 -4
  132. package/dist/esm/card-feature/index.js +131 -0
  133. package/dist/esm/card-feature/style.scss +75 -0
  134. package/dist/esm/card-form/index.js +134 -0
  135. package/dist/esm/card-form/style.scss +20 -0
  136. package/dist/esm/card-settings-group/index.js +61 -0
  137. package/dist/esm/card-settings-group/style.scss +33 -0
  138. package/dist/esm/card-sortable-list/index.js +425 -0
  139. package/dist/esm/card-sortable-list/style.scss +48 -0
  140. package/dist/esm/category-autocomplete/index.js +168 -140
  141. package/dist/esm/color-picker/index.js +39 -21
  142. package/dist/esm/color-picker/index.test.js +78 -0
  143. package/dist/esm/color-picker/style.scss +11 -13
  144. package/dist/esm/confirm-dialog/index.js +116 -0
  145. package/dist/esm/consts.js +1 -1
  146. package/dist/esm/custom-select-control/index.js +34 -15
  147. package/dist/esm/dataviews/index.js +34 -0
  148. package/dist/esm/dataviews/style.scss +9 -0
  149. package/dist/esm/divider/index.js +42 -0
  150. package/dist/esm/divider/style.scss +51 -0
  151. package/dist/esm/footer/index.js +34 -28
  152. package/dist/esm/form-token-field/index.js +46 -27
  153. package/dist/esm/form-token-field/style.scss +0 -5
  154. package/dist/esm/global-notices/index.js +3 -3
  155. package/dist/esm/grid/index.js +23 -13
  156. package/dist/esm/grid/style.scss +67 -8
  157. package/dist/esm/handoff/index.js +199 -143
  158. package/dist/esm/handoff-message/index.js +10 -6
  159. package/dist/esm/hooks/index.js +8 -6
  160. package/dist/esm/hooks/use-confirm-dialog.js +73 -0
  161. package/dist/esm/hooks/use-unsaved-changes-dialog.js +136 -0
  162. package/dist/esm/hooks/useObjectState.js +19 -9
  163. package/dist/esm/hooks/useObjectState.test.js +54 -32
  164. package/dist/esm/hooks/useOnClickOutside.js +4 -4
  165. package/dist/esm/hooks/usePrompt.js +9 -11
  166. package/dist/esm/image-upload/index.js +110 -111
  167. package/dist/esm/image-upload/index.test.js +11 -13
  168. package/dist/esm/image-upload/style.scss +12 -40
  169. package/dist/esm/index.js +13 -1
  170. package/dist/esm/info-button/index.js +38 -19
  171. package/dist/esm/integration-icons/active-campaign.js +16 -0
  172. package/dist/esm/integration-icons/constant-contact.js +22 -0
  173. package/dist/esm/integration-icons/fundraise-up.js +19 -0
  174. package/dist/esm/integration-icons/index.js +6 -0
  175. package/dist/esm/integration-icons/mailchimp.js +13 -0
  176. package/dist/esm/integration-icons/salesforce.js +11 -0
  177. package/dist/esm/integration-icons/wisepops.js +14 -0
  178. package/dist/esm/modal/index.js +27 -13
  179. package/dist/esm/modal/style.scss +47 -13
  180. package/dist/esm/newspack-icon/index.js +40 -25
  181. package/dist/esm/newspack-icon/style.scss +5 -3
  182. package/dist/esm/notice/index.js +59 -42
  183. package/dist/esm/plugin-installer/index.js +255 -243
  184. package/dist/esm/plugin-settings/SettingsSection.js +61 -50
  185. package/dist/esm/plugin-settings/index.js +212 -189
  186. package/dist/esm/plugin-toggle/index.js +187 -190
  187. package/dist/esm/popover/index.js +35 -16
  188. package/dist/esm/position-control/index.js +22 -18
  189. package/dist/esm/progress-bar/index.js +66 -49
  190. package/dist/esm/progress-bar/index.test.js +61 -67
  191. package/dist/esm/radio-control/index.js +34 -15
  192. package/dist/esm/section-header/index.js +140 -38
  193. package/dist/esm/section-header/style.scss +129 -4
  194. package/dist/esm/select-control/ButtonGroupControl.js +23 -18
  195. package/dist/esm/select-control/GroupedSelectControl.js +38 -30
  196. package/dist/esm/select-control/index.js +47 -27
  197. package/dist/esm/settings/MinMaxSetting.js +27 -16
  198. package/dist/esm/settings/SettingsCard.js +18 -13
  199. package/dist/esm/settings/SettingsSection.js +20 -19
  200. package/dist/esm/settings/index.js +3 -3
  201. package/dist/esm/sortable-newsletter-list-control/index.js +74 -53
  202. package/dist/esm/steps-list/index.js +39 -21
  203. package/dist/esm/steps-list-item/index.js +42 -26
  204. package/dist/esm/style-card/index.js +65 -50
  205. package/dist/esm/style.scss +2 -0
  206. package/dist/esm/tabbed-navigation/index.js +35 -33
  207. package/dist/esm/text-control/index.js +25 -19
  208. package/dist/esm/utils/color.js +4 -4
  209. package/dist/esm/utils/editor-toolbar-back-button.js +53 -0
  210. package/dist/esm/utils/index.js +29 -16
  211. package/dist/esm/utils/style.scss +8 -0
  212. package/dist/esm/waiting/index.js +43 -22
  213. package/dist/esm/waiting/style.scss +4 -0
  214. package/dist/esm/web-preview/index.js +218 -147
  215. package/dist/esm/web-preview/style.scss +20 -53
  216. package/dist/esm/with-wizard/index.js +360 -358
  217. package/dist/esm/with-wizard/style.scss +28 -60
  218. package/dist/esm/with-wizard-screen/index.js +48 -46
  219. package/dist/esm/with-wizard-screen/style.scss +77 -10
  220. package/dist/esm/wizard/components/WizardError.js +18 -16
  221. package/dist/esm/wizard/components/WizardSnackbar.js +70 -0
  222. package/dist/esm/wizard/components/style.scss +48 -0
  223. package/dist/esm/wizard/index.js +207 -58
  224. package/dist/esm/wizard/store/index.js +189 -90
  225. package/dist/esm/wizard/store/utils.js +13 -6
  226. package/package.json +30 -17
  227. package/release.config.js +1 -0
  228. package/shared/author-context.js +26 -0
  229. package/shared/hooks/use-coauthors.js +388 -0
  230. package/shared/hooks/use-coauthors.test.js +850 -0
  231. package/shared/hooks/use-custom-byline.js +47 -0
  232. package/shared/hooks/use-custom-byline.test.js +133 -0
  233. package/shared/scss/_mixins.scss +54 -0
  234. package/src/accordion/index.js +8 -7
  235. package/src/action-card/action-card.d.ts +7 -3
  236. package/src/action-card/index.js +39 -12
  237. package/src/action-card/style.scss +25 -2
  238. package/src/autocomplete-tokenfield/index.js +2 -2
  239. package/src/autocomplete-with-latest-posts/index.js +290 -0
  240. package/src/autocomplete-with-latest-posts/style.scss +150 -0
  241. package/src/badge/index.tsx +26 -0
  242. package/src/badge/style.scss +36 -0
  243. package/src/button/index.tsx +3 -2
  244. package/src/card/core-card.js +224 -0
  245. package/src/card/index.js +40 -1
  246. package/src/card/style-core.scss +295 -0
  247. package/src/card/style.scss +4 -4
  248. package/src/card-feature/README.md +188 -0
  249. package/src/card-feature/index.tsx +186 -0
  250. package/src/card-feature/style.scss +75 -0
  251. package/src/card-form/README.md +113 -0
  252. package/src/card-form/index.tsx +128 -0
  253. package/src/card-form/style.scss +20 -0
  254. package/src/card-settings-group/README.md +43 -0
  255. package/src/card-settings-group/index.tsx +78 -0
  256. package/src/card-settings-group/style.scss +33 -0
  257. package/src/card-sortable-list/README.md +51 -0
  258. package/src/card-sortable-list/index.tsx +435 -0
  259. package/src/card-sortable-list/style.scss +48 -0
  260. package/src/color-picker/index.js +15 -8
  261. package/src/color-picker/index.test.js +52 -0
  262. package/src/color-picker/style.scss +11 -13
  263. package/src/confirm-dialog/README.md +128 -0
  264. package/src/confirm-dialog/index.tsx +132 -0
  265. package/src/dataviews/index.tsx +34 -0
  266. package/src/dataviews/style.scss +9 -0
  267. package/src/divider/README.md +25 -0
  268. package/src/divider/index.js +31 -0
  269. package/src/divider/style.scss +51 -0
  270. package/src/form-token-field/style.scss +0 -5
  271. package/src/grid/index.tsx +2 -1
  272. package/src/grid/style.scss +67 -8
  273. package/src/handoff/README.md +93 -0
  274. package/src/handoff/index.js +46 -8
  275. package/src/hooks/index.ts +2 -1
  276. package/src/hooks/use-confirm-dialog.tsx +80 -0
  277. package/src/hooks/use-unsaved-changes-dialog.tsx +140 -0
  278. package/src/image-upload/index.js +10 -15
  279. package/src/image-upload/style.scss +12 -40
  280. package/src/index.js +13 -1
  281. package/src/integration-icons/active-campaign.js +14 -0
  282. package/src/integration-icons/constant-contact.js +22 -0
  283. package/src/integration-icons/fundraise-up.js +18 -0
  284. package/src/integration-icons/index.js +6 -0
  285. package/src/integration-icons/mailchimp.js +10 -0
  286. package/src/integration-icons/salesforce.js +10 -0
  287. package/src/integration-icons/wisepops.js +14 -0
  288. package/src/modal/index.js +12 -3
  289. package/src/modal/style.scss +47 -13
  290. package/src/newspack-icon/style.scss +5 -3
  291. package/src/plugin-installer/index.js +30 -19
  292. package/src/section-header/index.js +105 -14
  293. package/src/section-header/style.scss +129 -4
  294. package/src/select-control/index.js +4 -0
  295. package/src/style.scss +2 -0
  296. package/src/utils/editor-toolbar-back-button.tsx +48 -0
  297. package/src/utils/index.js +6 -0
  298. package/src/utils/style.scss +8 -0
  299. package/src/waiting/index.js +2 -1
  300. package/src/waiting/style.scss +4 -0
  301. package/src/web-preview/index.js +90 -35
  302. package/src/web-preview/style.scss +20 -53
  303. package/src/with-wizard/index.js +1 -1
  304. package/src/with-wizard/style.scss +28 -60
  305. package/src/with-wizard-screen/style.scss +77 -10
  306. package/src/wizard/components/WizardSnackbar.js +58 -0
  307. package/src/wizard/components/style.scss +48 -0
  308. package/src/wizard/index.js +152 -21
  309. package/src/wizard/store/index.js +28 -2
  310. package/dist/cjs/button-card/index.js +0 -72
  311. package/dist/cjs/button-card/style.scss +0 -180
  312. package/dist/esm/button-card/index.js +0 -64
  313. package/dist/esm/button-card/style.scss +0 -180
  314. package/src/button-card/index.js +0 -52
  315. package/src/button-card/style.scss +0 -180
@@ -0,0 +1,850 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import { renderHook, act, waitFor } from '@testing-library/react';
5
+
6
+ /**
7
+ * WordPress dependencies
8
+ */
9
+ import { useSelect } from '@wordpress/data';
10
+ import apiFetch from '@wordpress/api-fetch';
11
+
12
+ /**
13
+ * Internal dependencies
14
+ */
15
+ import { useCoAuthors, resetGuestAvatarCacheForTests, resetCoauthorDetailsCacheForTests } from './use-coauthors';
16
+
17
+ jest.mock( '@wordpress/data', () => ( {
18
+ useSelect: jest.fn(),
19
+ } ) );
20
+
21
+ jest.mock( '@wordpress/core-data', () => ( {
22
+ store: 'core',
23
+ } ) );
24
+
25
+ jest.mock( '@wordpress/api-fetch', () => jest.fn() );
26
+
27
+ /**
28
+ * Helper to create a mock select function for multiple stores.
29
+ *
30
+ * @param {Object} options Mock options.
31
+ * @param {Object} options.capStore Legacy CAP store mock (null if unavailable — i.e., new CAP).
32
+ * @param {number} options.currentPostId Currently-edited post ID.
33
+ * @param {Object} options.entityRecords Map of postId -> post entity record (used for Query Loop).
34
+ * @param {number[]} options.coauthorTermIds `coauthors` post attribute (new CAP).
35
+ * `undefined` means attribute not set (legacy CAP / plugin missing);
36
+ * `[]` means the attribute is present but empty.
37
+ * @return {Function} Mock select function.
38
+ */
39
+ const createMockSelect = ( { capStore = null, currentPostId = 123, entityRecords = {}, coauthorTermIds } = {} ) => {
40
+ return storeName => {
41
+ if ( storeName === 'cap/authors' ) {
42
+ return capStore;
43
+ }
44
+ if ( storeName === 'core/editor' ) {
45
+ return {
46
+ getCurrentPostId: () => currentPostId,
47
+ getEditedPostAttribute: attr => ( attr === 'coauthors' ? coauthorTermIds : undefined ),
48
+ };
49
+ }
50
+ if ( storeName === 'core' ) {
51
+ return {
52
+ getEntityRecord: ( kind, type, id ) => entityRecords[ id ] || null,
53
+ };
54
+ }
55
+ return null;
56
+ };
57
+ };
58
+
59
+ describe( 'useCoAuthors', () => {
60
+ beforeEach( () => {
61
+ jest.clearAllMocks();
62
+ resetGuestAvatarCacheForTests();
63
+ resetCoauthorDetailsCacheForTests();
64
+ // Default: apiFetch resolves with empty object (no avatar_urls).
65
+ apiFetch.mockResolvedValue( {} );
66
+ } );
67
+
68
+ describe( 'CAP store availability', () => {
69
+ it( 'should return isCapAvailable false when CAP store is not available', () => {
70
+ useSelect.mockImplementation( callback => callback( createMockSelect( { capStore: null } ) ) );
71
+
72
+ const { result } = renderHook( () => useCoAuthors( 123 ) );
73
+
74
+ expect( result.current.authors ).toEqual( [] );
75
+ expect( result.current.isCapAvailable ).toBe( false );
76
+ } );
77
+
78
+ it( 'should return isCapAvailable false when getAuthors is not a function', () => {
79
+ useSelect.mockImplementation( callback => callback( createMockSelect( { capStore: {} } ) ) );
80
+
81
+ const { result } = renderHook( () => useCoAuthors( 123 ) );
82
+
83
+ expect( result.current.authors ).toEqual( [] );
84
+ expect( result.current.isCapAvailable ).toBe( false );
85
+ } );
86
+ } );
87
+
88
+ describe( 'currently-edited post (uses CAP store)', () => {
89
+ it( 'should return empty authors when CAP is available but no authors assigned', () => {
90
+ useSelect.mockImplementation( callback =>
91
+ callback(
92
+ createMockSelect( {
93
+ capStore: { getAuthors: () => [] },
94
+ currentPostId: 123,
95
+ } )
96
+ )
97
+ );
98
+
99
+ const { result } = renderHook( () => useCoAuthors( 123 ) );
100
+
101
+ expect( result.current.authors ).toEqual( [] );
102
+ expect( result.current.isCapAvailable ).toBe( true );
103
+ } );
104
+
105
+ it( 'should return empty authors when postId is falsy', () => {
106
+ const getAuthorsMock = jest.fn( () => [] );
107
+ useSelect.mockImplementation( callback =>
108
+ callback(
109
+ createMockSelect( {
110
+ capStore: { getAuthors: getAuthorsMock },
111
+ currentPostId: 123,
112
+ } )
113
+ )
114
+ );
115
+
116
+ const { result } = renderHook( () => useCoAuthors( null ) );
117
+
118
+ expect( result.current.authors ).toEqual( [] );
119
+ expect( result.current.isCapAvailable ).toBe( true );
120
+ } );
121
+
122
+ it( 'should map CAP authors to expected format', () => {
123
+ const capAuthors = [
124
+ { id: 1, display: 'Jane Doe', value: 'jane-doe', label: 'Jane' },
125
+ { id: 2, display: 'John Smith', value: 'john-smith', label: 'John' },
126
+ ];
127
+
128
+ useSelect.mockImplementation( callback =>
129
+ callback(
130
+ createMockSelect( {
131
+ capStore: { getAuthors: () => capAuthors },
132
+ currentPostId: 123,
133
+ } )
134
+ )
135
+ );
136
+
137
+ const { result } = renderHook( () => useCoAuthors( 123 ) );
138
+
139
+ expect( result.current.authors ).toEqual( [
140
+ { id: 1, display_name: 'Jane Doe', user_nicename: 'jane-doe', isGuest: false },
141
+ { id: 2, display_name: 'John Smith', user_nicename: 'john-smith', isGuest: false },
142
+ ] );
143
+ expect( result.current.isCapAvailable ).toBe( true );
144
+ } );
145
+
146
+ it( 'should set isGuest to true for guest-author userType', () => {
147
+ const capAuthors = [
148
+ { id: 1, display: 'Jane Doe', value: 'jane-doe', userType: 'wpuser' },
149
+ { id: 1591, display: 'Guest Writer', value: 'guest-writer', userType: 'guest-author' },
150
+ ];
151
+
152
+ useSelect.mockImplementation( callback =>
153
+ callback(
154
+ createMockSelect( {
155
+ capStore: { getAuthors: () => capAuthors },
156
+ currentPostId: 123,
157
+ } )
158
+ )
159
+ );
160
+
161
+ const { result } = renderHook( () => useCoAuthors( 123 ) );
162
+
163
+ expect( result.current.authors[ 0 ].isGuest ).toBe( false );
164
+ expect( result.current.authors[ 1 ].isGuest ).toBe( true );
165
+ } );
166
+
167
+ it( 'should fallback to value then label for display_name', () => {
168
+ const capAuthors = [
169
+ { id: 1, value: 'from-value', label: 'From Label' }, // no display
170
+ { id: 2, label: 'Only Label' }, // no display or value
171
+ ];
172
+
173
+ useSelect.mockImplementation( callback =>
174
+ callback(
175
+ createMockSelect( {
176
+ capStore: { getAuthors: () => capAuthors },
177
+ currentPostId: 123,
178
+ } )
179
+ )
180
+ );
181
+
182
+ const { result } = renderHook( () => useCoAuthors( 123 ) );
183
+
184
+ expect( result.current.authors[ 0 ].display_name ).toBe( 'from-value' );
185
+ expect( result.current.authors[ 1 ].display_name ).toBe( 'Only Label' );
186
+ } );
187
+ } );
188
+
189
+ describe( 'currently-edited post (new CAP, term ID resolution)', () => {
190
+ const NEW_CAP_RESPONSE = [
191
+ { id: '1', termId: 471, displayName: 'Jane Doe', userNicename: 'jane-doe', userType: 'wpuser', login: 'jane', email: 'jane@x.com' },
192
+ { id: '2', termId: 488, displayName: 'John Smith', userNicename: 'john-smith', userType: 'wpuser', login: 'john', email: 'john@x.com' },
193
+ { id: '1591', termId: 483, displayName: 'External', userNicename: 'external', userType: 'guest-author', login: 'external', email: '' },
194
+ ];
195
+
196
+ it( 'should resolve term IDs via REST when legacy CAP store is absent', async () => {
197
+ apiFetch.mockResolvedValueOnce( NEW_CAP_RESPONSE );
198
+
199
+ useSelect.mockImplementation( callback =>
200
+ callback(
201
+ createMockSelect( {
202
+ capStore: null, // legacy CAP not registered
203
+ currentPostId: 123,
204
+ coauthorTermIds: [ 471, 488, 483 ],
205
+ } )
206
+ )
207
+ );
208
+
209
+ const { result } = renderHook( () => useCoAuthors( 123 ) );
210
+
211
+ await waitFor( () => {
212
+ expect( result.current.authors ).toHaveLength( 3 );
213
+ } );
214
+
215
+ expect( apiFetch ).toHaveBeenCalledWith( {
216
+ path: '/coauthors/v1/authors-by-term-ids?ids=471,488,483',
217
+ } );
218
+ expect( result.current.authors[ 0 ] ).toMatchObject( {
219
+ id: 1,
220
+ termId: 471,
221
+ display_name: 'Jane Doe',
222
+ user_nicename: 'jane-doe',
223
+ isGuest: false,
224
+ } );
225
+ expect( result.current.authors[ 2 ].isGuest ).toBe( true );
226
+ expect( result.current.isCapAvailable ).toBe( true );
227
+ expect( result.current.hasCoauthorTermIds ).toBe( true );
228
+ } );
229
+
230
+ it( 'should return empty authors when coauthors attribute is an empty array', async () => {
231
+ useSelect.mockImplementation( callback =>
232
+ callback(
233
+ createMockSelect( {
234
+ capStore: null,
235
+ currentPostId: 123,
236
+ coauthorTermIds: [],
237
+ } )
238
+ )
239
+ );
240
+
241
+ const { result } = renderHook( () => useCoAuthors( 123 ) );
242
+
243
+ await act( () => Promise.resolve() );
244
+
245
+ expect( result.current.authors ).toEqual( [] );
246
+ expect( result.current.isCapAvailable ).toBe( true );
247
+ expect( apiFetch ).not.toHaveBeenCalled();
248
+ } );
249
+
250
+ it( 'should mark isCapAvailable false when neither legacy store nor coauthors attribute are present', () => {
251
+ useSelect.mockImplementation( callback =>
252
+ callback(
253
+ createMockSelect( {
254
+ capStore: null,
255
+ currentPostId: 123,
256
+ // coauthorTermIds undefined
257
+ } )
258
+ )
259
+ );
260
+
261
+ const { result } = renderHook( () => useCoAuthors( 123 ) );
262
+
263
+ expect( result.current.isCapAvailable ).toBe( false );
264
+ expect( result.current.authors ).toEqual( [] );
265
+ } );
266
+
267
+ it( 'should prefer legacy CAP store when both legacy store and coauthors attribute are present', async () => {
268
+ // This simulates an in-flight upgrade scenario where old JS is still loaded.
269
+ // Legacy path should win to keep behavior consistent.
270
+ useSelect.mockImplementation( callback =>
271
+ callback(
272
+ createMockSelect( {
273
+ capStore: {
274
+ getAuthors: () => [ { id: 99, display: 'Legacy', value: 'legacy', userType: 'wpuser' } ],
275
+ },
276
+ currentPostId: 123,
277
+ coauthorTermIds: [ 471 ],
278
+ } )
279
+ )
280
+ );
281
+
282
+ const { result } = renderHook( () => useCoAuthors( 123 ) );
283
+
284
+ await act( () => Promise.resolve() );
285
+
286
+ expect( apiFetch ).not.toHaveBeenCalled();
287
+ expect( result.current.authors ).toEqual( [ { id: 99, display_name: 'Legacy', user_nicename: 'legacy', isGuest: false } ] );
288
+ } );
289
+
290
+ it( 'should cache results so subsequent mounts skip the REST call', async () => {
291
+ apiFetch.mockResolvedValue( NEW_CAP_RESPONSE );
292
+
293
+ useSelect.mockImplementation( callback =>
294
+ callback(
295
+ createMockSelect( {
296
+ capStore: null,
297
+ currentPostId: 123,
298
+ coauthorTermIds: [ 471, 488, 483 ],
299
+ } )
300
+ )
301
+ );
302
+
303
+ const { result: result1 } = renderHook( () => useCoAuthors( 123 ) );
304
+
305
+ await waitFor( () => {
306
+ expect( result1.current.authors ).toHaveLength( 3 );
307
+ } );
308
+
309
+ const callsAfterFirstMount = apiFetch.mock.calls.length;
310
+
311
+ // A later mount re-uses the cache and makes no additional calls.
312
+ const { result: result2 } = renderHook( () => useCoAuthors( 123 ) );
313
+
314
+ await waitFor( () => {
315
+ expect( result2.current.authors ).toHaveLength( 3 );
316
+ } );
317
+
318
+ expect( apiFetch ).toHaveBeenCalledTimes( callsAfterFirstMount );
319
+ } );
320
+
321
+ it( 'should silently drop term IDs that do not resolve', async () => {
322
+ // REST returns only 2 of 3 requested IDs (the third was deleted).
323
+ apiFetch.mockResolvedValueOnce( [ NEW_CAP_RESPONSE[ 0 ], NEW_CAP_RESPONSE[ 1 ] ] );
324
+
325
+ useSelect.mockImplementation( callback =>
326
+ callback(
327
+ createMockSelect( {
328
+ capStore: null,
329
+ currentPostId: 123,
330
+ coauthorTermIds: [ 471, 488, 99999 ],
331
+ } )
332
+ )
333
+ );
334
+
335
+ const { result } = renderHook( () => useCoAuthors( 123 ) );
336
+
337
+ await waitFor( () => {
338
+ expect( result.current.authors ).toHaveLength( 2 );
339
+ } );
340
+
341
+ expect( result.current.authors.map( a => a.termId ) ).toEqual( [ 471, 488 ] );
342
+ } );
343
+
344
+ it( 'should handle REST errors without blocking the component', async () => {
345
+ apiFetch.mockRejectedValueOnce( new Error( 'Server error' ) );
346
+
347
+ useSelect.mockImplementation( callback =>
348
+ callback(
349
+ createMockSelect( {
350
+ capStore: null,
351
+ currentPostId: 123,
352
+ coauthorTermIds: [ 471 ],
353
+ } )
354
+ )
355
+ );
356
+
357
+ const { result } = renderHook( () => useCoAuthors( 123 ) );
358
+
359
+ // Allow the failed promise to settle.
360
+ await act( () => Promise.resolve().then( () => Promise.resolve() ) );
361
+
362
+ expect( result.current.authors ).toEqual( [] );
363
+ expect( result.current.isCapAvailable ).toBe( true );
364
+ // Term IDs are assigned but resolution returned empty — surface this so consumers
365
+ // don't silently fall back to `post_author` and credit the wrong person.
366
+ expect( result.current.hasCoauthorTermIds ).toBe( true );
367
+ } );
368
+
369
+ it( 'should allow retry after a transient REST error (no negative caching on catch)', async () => {
370
+ const RETRY_RESPONSE = [
371
+ { id: '1', termId: 471, displayName: 'Jane', userNicename: 'jane', userType: 'wpuser', login: 'jane', email: '' },
372
+ ];
373
+
374
+ useSelect.mockImplementation( callback =>
375
+ callback(
376
+ createMockSelect( {
377
+ capStore: null,
378
+ currentPostId: 123,
379
+ coauthorTermIds: [ 471 ],
380
+ } )
381
+ )
382
+ );
383
+
384
+ // First attempt fails with a transient error; cache should NOT be poisoned.
385
+ apiFetch.mockRejectedValueOnce( new Error( 'Transient 500' ) );
386
+
387
+ const { result: result1, unmount: unmount1 } = renderHook( () => useCoAuthors( 123 ) );
388
+
389
+ await act( () => Promise.resolve().then( () => Promise.resolve() ) );
390
+
391
+ expect( result1.current.authors ).toEqual( [] );
392
+ expect( apiFetch ).toHaveBeenCalledTimes( 1 );
393
+
394
+ unmount1();
395
+
396
+ // Second attempt succeeds — we should actually re-fetch (not a cached failure).
397
+ apiFetch.mockResolvedValueOnce( RETRY_RESPONSE );
398
+
399
+ const { result: result2 } = renderHook( () => useCoAuthors( 123 ) );
400
+
401
+ await waitFor( () => {
402
+ expect( result2.current.authors ).toHaveLength( 1 );
403
+ } );
404
+
405
+ expect( apiFetch ).toHaveBeenCalledTimes( 2 );
406
+ expect( result2.current.authors[ 0 ].display_name ).toBe( 'Jane' );
407
+ } );
408
+ } );
409
+
410
+ describe( 'Query Loop context (uses REST API)', () => {
411
+ it( 'should use REST API when postId differs from currently-edited post', () => {
412
+ const restAuthors = [
413
+ { id: 1, display_name: 'Jane Doe', author_link: '/author/jane/' },
414
+ { id: 2, display_name: 'John Smith', author_link: '/author/john/' },
415
+ ];
416
+
417
+ useSelect.mockImplementation( callback =>
418
+ callback(
419
+ createMockSelect( {
420
+ capStore: { getAuthors: () => [] },
421
+ currentPostId: 123, // Editing post 123
422
+ entityRecords: {
423
+ 456: { newspack_author_info: restAuthors }, // Query Loop post 456
424
+ },
425
+ } )
426
+ )
427
+ );
428
+
429
+ // Request authors for post 456 (different from current 123)
430
+ const { result } = renderHook( () => useCoAuthors( 456, 'post' ) );
431
+
432
+ expect( result.current.authors ).toEqual( [
433
+ { id: 1, display_name: 'Jane Doe', author_link: '/author/jane/', user_nicename: 'jane' },
434
+ { id: 2, display_name: 'John Smith', author_link: '/author/john/', user_nicename: 'john' },
435
+ ] );
436
+ expect( result.current.isCapAvailable ).toBe( true );
437
+ } );
438
+
439
+ it( 'should return empty authors when REST API has no author info', () => {
440
+ useSelect.mockImplementation( callback =>
441
+ callback(
442
+ createMockSelect( {
443
+ capStore: { getAuthors: () => [] },
444
+ currentPostId: 123,
445
+ entityRecords: {
446
+ 456: { newspack_author_info: null },
447
+ },
448
+ } )
449
+ )
450
+ );
451
+
452
+ const { result } = renderHook( () => useCoAuthors( 456, 'post' ) );
453
+
454
+ expect( result.current.authors ).toEqual( [] );
455
+ expect( result.current.isCapAvailable ).toBe( true );
456
+ } );
457
+
458
+ it( 'should extract user_nicename from author_link', () => {
459
+ const restAuthors = [
460
+ { id: 1591, display_name: 'External Contributor', author_link: 'https://example.com/author/external-contributor/' },
461
+ ];
462
+
463
+ useSelect.mockImplementation( callback =>
464
+ callback(
465
+ createMockSelect( {
466
+ capStore: { getAuthors: () => [] },
467
+ currentPostId: 123,
468
+ entityRecords: {
469
+ 456: { newspack_author_info: restAuthors },
470
+ },
471
+ } )
472
+ )
473
+ );
474
+
475
+ const { result } = renderHook( () => useCoAuthors( 456, 'post' ) );
476
+
477
+ expect( result.current.authors[ 0 ].user_nicename ).toBe( 'external-contributor' );
478
+ } );
479
+
480
+ it( 'should strip query params and hash fragments from author_link', () => {
481
+ const restAuthors = [
482
+ { id: 1, display_name: 'Jane', author_link: '/author/jane/?utm_source=feed' },
483
+ { id: 2, display_name: 'John', author_link: '/author/john/#bio' },
484
+ { id: 3, display_name: 'Jill', author_link: '/author/jill/?x=1#top' },
485
+ ];
486
+
487
+ useSelect.mockImplementation( callback =>
488
+ callback(
489
+ createMockSelect( {
490
+ capStore: { getAuthors: () => [] },
491
+ currentPostId: 123,
492
+ entityRecords: {
493
+ 456: { newspack_author_info: restAuthors },
494
+ },
495
+ } )
496
+ )
497
+ );
498
+
499
+ const { result } = renderHook( () => useCoAuthors( 456, 'post' ) );
500
+
501
+ expect( result.current.authors[ 0 ].user_nicename ).toBe( 'jane' );
502
+ expect( result.current.authors[ 1 ].user_nicename ).toBe( 'john' );
503
+ expect( result.current.authors[ 2 ].user_nicename ).toBe( 'jill' );
504
+ } );
505
+
506
+ it( 'should return undefined user_nicename when author_link is missing or unusable', () => {
507
+ const restAuthors = [
508
+ { id: 1, display_name: 'No Link Author', author_link: null },
509
+ { id: 2, display_name: 'Empty Link Author', author_link: '' },
510
+ { id: 3, display_name: 'Root URL Author', author_link: 'https://example.com' },
511
+ { id: 4, display_name: 'Plain Permalink Author', author_link: '/?author=123' },
512
+ ];
513
+
514
+ useSelect.mockImplementation( callback =>
515
+ callback(
516
+ createMockSelect( {
517
+ capStore: { getAuthors: () => [] },
518
+ currentPostId: 123,
519
+ entityRecords: {
520
+ 456: { newspack_author_info: restAuthors },
521
+ },
522
+ } )
523
+ )
524
+ );
525
+
526
+ const { result } = renderHook( () => useCoAuthors( 456, 'post' ) );
527
+
528
+ expect( result.current.authors[ 0 ].user_nicename ).toBeUndefined();
529
+ expect( result.current.authors[ 1 ].user_nicename ).toBeUndefined();
530
+ expect( result.current.authors[ 2 ].user_nicename ).toBeUndefined();
531
+ expect( result.current.authors[ 3 ].user_nicename ).toBeUndefined();
532
+ } );
533
+
534
+ it( 'should fetch avatars for Query Loop authors via CAP endpoint when not enriched', async () => {
535
+ const avatarUrls = { 96: 'https://example.com/guest-96.jpg' };
536
+ apiFetch.mockResolvedValue( { avatar_urls: avatarUrls } );
537
+
538
+ const restAuthors = [
539
+ { id: 1591, display_name: 'External Contributor', author_link: 'https://example.com/author/external-contributor/' },
540
+ ];
541
+
542
+ useSelect.mockImplementation( callback =>
543
+ callback(
544
+ createMockSelect( {
545
+ capStore: { getAuthors: () => [] },
546
+ currentPostId: 123,
547
+ entityRecords: {
548
+ 456: { newspack_author_info: restAuthors },
549
+ },
550
+ } )
551
+ )
552
+ );
553
+
554
+ const { result } = renderHook( () => useCoAuthors( 456, 'post' ) );
555
+
556
+ await waitFor( () => {
557
+ expect( result.current.authors[ 0 ].avatar_urls ).toEqual( avatarUrls );
558
+ } );
559
+
560
+ expect( apiFetch ).toHaveBeenCalledWith( {
561
+ path: '/coauthors/v1/coauthors/external-contributor',
562
+ } );
563
+ } );
564
+
565
+ it( 'should use enriched user_nicename directly when available', () => {
566
+ const restAuthors = [ { id: 1, display_name: 'Jane Doe', author_link: '/author/jane/', user_nicename: 'jane-doe-enriched' } ];
567
+
568
+ useSelect.mockImplementation( callback =>
569
+ callback(
570
+ createMockSelect( {
571
+ capStore: { getAuthors: () => [] },
572
+ currentPostId: 123,
573
+ entityRecords: {
574
+ 456: { newspack_author_info: restAuthors },
575
+ },
576
+ } )
577
+ )
578
+ );
579
+
580
+ const { result } = renderHook( () => useCoAuthors( 456, 'post' ) );
581
+
582
+ expect( result.current.authors[ 0 ].user_nicename ).toBe( 'jane-doe-enriched' );
583
+ } );
584
+
585
+ it( 'should map is_guest from enriched REST data', () => {
586
+ const restAuthors = [
587
+ { id: 1, display_name: 'WP User', author_link: '/author/wp-user/', user_nicename: 'wp-user', is_guest: false },
588
+ { id: 2, display_name: 'Guest', author_link: '/author/guest/', user_nicename: 'guest', is_guest: true },
589
+ ];
590
+
591
+ useSelect.mockImplementation( callback =>
592
+ callback(
593
+ createMockSelect( {
594
+ capStore: { getAuthors: () => [] },
595
+ currentPostId: 123,
596
+ entityRecords: {
597
+ 456: { newspack_author_info: restAuthors },
598
+ },
599
+ } )
600
+ )
601
+ );
602
+
603
+ const { result } = renderHook( () => useCoAuthors( 456, 'post' ) );
604
+
605
+ expect( result.current.authors[ 0 ].isGuest ).toBe( false );
606
+ expect( result.current.authors[ 1 ].isGuest ).toBe( true );
607
+ } );
608
+
609
+ it( 'should pass through avatar_urls from enriched REST data', () => {
610
+ const avatarUrls = { 96: 'https://example.com/avatar-96.jpg' };
611
+ const restAuthors = [
612
+ { id: 1, display_name: 'Jane', author_link: '/author/jane/', user_nicename: 'jane', is_guest: false, avatar_urls: avatarUrls },
613
+ ];
614
+
615
+ useSelect.mockImplementation( callback =>
616
+ callback(
617
+ createMockSelect( {
618
+ capStore: { getAuthors: () => [] },
619
+ currentPostId: 123,
620
+ entityRecords: {
621
+ 456: { newspack_author_info: restAuthors },
622
+ },
623
+ } )
624
+ )
625
+ );
626
+
627
+ const { result } = renderHook( () => useCoAuthors( 456, 'post' ) );
628
+
629
+ expect( result.current.authors[ 0 ].avatar_urls ).toEqual( avatarUrls );
630
+ } );
631
+
632
+ it( 'should skip avatar fetch for enriched authors with is_guest false', async () => {
633
+ const restAuthors = [ { id: 1, display_name: 'WP User', author_link: '/author/wp-user/', user_nicename: 'wp-user', is_guest: false } ];
634
+
635
+ useSelect.mockImplementation( callback =>
636
+ callback(
637
+ createMockSelect( {
638
+ capStore: { getAuthors: () => [] },
639
+ currentPostId: 123,
640
+ entityRecords: {
641
+ 456: { newspack_author_info: restAuthors },
642
+ },
643
+ } )
644
+ )
645
+ );
646
+
647
+ renderHook( () => useCoAuthors( 456, 'post' ) );
648
+
649
+ await act( () => Promise.resolve() );
650
+
651
+ expect( apiFetch ).not.toHaveBeenCalled();
652
+ } );
653
+
654
+ it( 'should skip avatar fetch when avatar_urls are already in REST data', async () => {
655
+ const avatarUrls = { 96: 'https://example.com/guest-96.jpg' };
656
+ const restAuthors = [
657
+ {
658
+ id: 1591,
659
+ display_name: 'Guest Author',
660
+ author_link: '/author/guest/',
661
+ user_nicename: 'guest',
662
+ is_guest: true,
663
+ avatar_urls: avatarUrls,
664
+ },
665
+ ];
666
+
667
+ useSelect.mockImplementation( callback =>
668
+ callback(
669
+ createMockSelect( {
670
+ capStore: { getAuthors: () => [] },
671
+ currentPostId: 123,
672
+ entityRecords: {
673
+ 456: { newspack_author_info: restAuthors },
674
+ },
675
+ } )
676
+ )
677
+ );
678
+
679
+ renderHook( () => useCoAuthors( 456, 'post' ) );
680
+
681
+ await act( () => Promise.resolve() );
682
+
683
+ expect( apiFetch ).not.toHaveBeenCalled();
684
+ } );
685
+
686
+ it( 'should return empty authors when post entity is not loaded', () => {
687
+ useSelect.mockImplementation( callback =>
688
+ callback(
689
+ createMockSelect( {
690
+ capStore: { getAuthors: () => [] },
691
+ currentPostId: 123,
692
+ entityRecords: {}, // No entity records
693
+ } )
694
+ )
695
+ );
696
+
697
+ const { result } = renderHook( () => useCoAuthors( 456, 'post' ) );
698
+
699
+ expect( result.current.authors ).toEqual( [] );
700
+ expect( result.current.isCapAvailable ).toBe( true );
701
+ } );
702
+ } );
703
+
704
+ describe( 'guest author avatar fetching', () => {
705
+ const GUEST_AVATAR_URLS = { 24: 'https://example.com/guest-24.jpg', 96: 'https://example.com/guest-96.jpg' };
706
+
707
+ const setupWithGuest = capAuthors => {
708
+ useSelect.mockImplementation( callback =>
709
+ callback(
710
+ createMockSelect( {
711
+ capStore: { getAuthors: () => capAuthors },
712
+ currentPostId: 123,
713
+ } )
714
+ )
715
+ );
716
+ };
717
+
718
+ it( 'should fetch avatar by nicename for guest authors', async () => {
719
+ apiFetch.mockResolvedValue( { avatar_urls: GUEST_AVATAR_URLS } );
720
+ setupWithGuest( [
721
+ { id: 1, display: 'Jane', value: 'jane-doe', userType: 'wpuser' },
722
+ { id: 1591, display: 'Guest Writer', value: 'guest-writer', userType: 'guest-author' },
723
+ ] );
724
+
725
+ const { result } = renderHook( () => useCoAuthors( 123 ) );
726
+
727
+ await waitFor( () => {
728
+ const guest = result.current.authors.find( a => a.id === 1591 );
729
+ expect( guest.avatar_urls ).toEqual( GUEST_AVATAR_URLS );
730
+ } );
731
+
732
+ expect( apiFetch ).toHaveBeenCalledWith( {
733
+ path: '/coauthors/v1/coauthors/guest-writer',
734
+ } );
735
+ } );
736
+
737
+ it( 'should not fetch avatars for WP users', async () => {
738
+ setupWithGuest( [
739
+ { id: 1, display: 'Jane', value: 'jane-doe', userType: 'wpuser' },
740
+ { id: 2, display: 'John', value: 'john-smith', userType: 'wpuser' },
741
+ ] );
742
+
743
+ renderHook( () => useCoAuthors( 123 ) );
744
+
745
+ // Allow any pending effects to flush.
746
+ await act( () => Promise.resolve() );
747
+
748
+ expect( apiFetch ).not.toHaveBeenCalled();
749
+ } );
750
+
751
+ it( 'should not fetch avatars when skip is true', async () => {
752
+ apiFetch.mockResolvedValue( { avatar_urls: GUEST_AVATAR_URLS } );
753
+ setupWithGuest( [ { id: 1591, display: 'Guest Writer', value: 'guest-writer', userType: 'guest-author' } ] );
754
+
755
+ renderHook( () => useCoAuthors( 123, 'post', true ) );
756
+
757
+ await act( () => Promise.resolve() );
758
+
759
+ expect( apiFetch ).not.toHaveBeenCalled();
760
+ } );
761
+
762
+ it( 'should not modify WP user authors when merging avatar data', async () => {
763
+ apiFetch.mockResolvedValue( { avatar_urls: GUEST_AVATAR_URLS } );
764
+ setupWithGuest( [
765
+ { id: 1, display: 'Jane', value: 'jane-doe', userType: 'wpuser' },
766
+ { id: 1591, display: 'Guest Writer', value: 'guest-writer', userType: 'guest-author' },
767
+ ] );
768
+
769
+ const { result } = renderHook( () => useCoAuthors( 123 ) );
770
+
771
+ await waitFor( () => {
772
+ expect( result.current.authors.find( a => a.id === 1591 ).avatar_urls ).toEqual( GUEST_AVATAR_URLS );
773
+ } );
774
+
775
+ // WP user should not have avatar_urls added by the hook.
776
+ const wpUser = result.current.authors.find( a => a.id === 1 );
777
+ expect( wpUser.avatar_urls ).toBeUndefined();
778
+ } );
779
+
780
+ it( 'should not fetch avatar for guest author without user_nicename', async () => {
781
+ setupWithGuest( [ { id: 1591, display: 'Guest Writer', value: undefined, userType: 'guest-author' } ] );
782
+
783
+ renderHook( () => useCoAuthors( 123 ) );
784
+
785
+ await act( () => Promise.resolve() );
786
+
787
+ expect( apiFetch ).not.toHaveBeenCalled();
788
+ } );
789
+
790
+ it( 'should encode nicename in the REST path', async () => {
791
+ apiFetch.mockResolvedValue( { avatar_urls: GUEST_AVATAR_URLS } );
792
+ setupWithGuest( [ { id: 1591, display: 'Guest Writer', value: 'name with spaces', userType: 'guest-author' } ] );
793
+
794
+ renderHook( () => useCoAuthors( 123 ) );
795
+
796
+ await waitFor( () => {
797
+ expect( apiFetch ).toHaveBeenCalledWith( {
798
+ path: '/coauthors/v1/coauthors/name%20with%20spaces',
799
+ } );
800
+ } );
801
+ } );
802
+
803
+ it( 'should handle failed fetches gracefully', async () => {
804
+ apiFetch.mockRejectedValue( new Error( 'Not found' ) );
805
+ setupWithGuest( [ { id: 1591, display: 'Guest Writer', value: 'ghost-author', userType: 'guest-author' } ] );
806
+
807
+ const { result } = renderHook( () => useCoAuthors( 123 ) );
808
+
809
+ // Allow the failed promise to settle.
810
+ await act( () => Promise.resolve().then( () => Promise.resolve() ) );
811
+
812
+ // Guest author should still be in the list, just without avatar_urls.
813
+ expect( result.current.authors ).toHaveLength( 1 );
814
+ expect( result.current.authors[ 0 ].display_name ).toBe( 'Guest Writer' );
815
+ } );
816
+
817
+ it( 'should deduplicate concurrent avatar fetches for the same nicename', async () => {
818
+ apiFetch.mockResolvedValue( { avatar_urls: GUEST_AVATAR_URLS } );
819
+
820
+ // Two Query Loop posts sharing the same guest author.
821
+ const sharedAuthor = { id: 1591, display_name: 'Guest Writer', author_link: '/author/guest-writer/' };
822
+
823
+ useSelect.mockImplementation( callback =>
824
+ callback(
825
+ createMockSelect( {
826
+ capStore: { getAuthors: () => [] },
827
+ currentPostId: 123,
828
+ entityRecords: {
829
+ 456: { newspack_author_info: [ sharedAuthor ] },
830
+ 789: { newspack_author_info: [ sharedAuthor ] },
831
+ },
832
+ } )
833
+ )
834
+ );
835
+
836
+ // Render two hooks concurrently, simulating two avatar blocks in a Query Loop.
837
+ const { result: result1 } = renderHook( () => useCoAuthors( 456, 'post' ) );
838
+ const { result: result2 } = renderHook( () => useCoAuthors( 789, 'post' ) );
839
+
840
+ await waitFor( () => {
841
+ expect( result1.current.authors[ 0 ].avatar_urls ).toEqual( GUEST_AVATAR_URLS );
842
+ expect( result2.current.authors[ 0 ].avatar_urls ).toEqual( GUEST_AVATAR_URLS );
843
+ } );
844
+
845
+ // Only one apiFetch call should have been made despite two concurrent hooks.
846
+ const guestWriterCalls = apiFetch.mock.calls.filter( ( [ arg ] ) => arg.path === '/coauthors/v1/coauthors/guest-writer' );
847
+ expect( guestWriterCalls ).toHaveLength( 1 );
848
+ } );
849
+ } );
850
+ } );