Parcourir la source

[web] Update `fetch-mock` to version 12 (#24837)

* Update fetch-mock to version 12

* Replace `fetchMock.done` by `fetchMock.callHistory.done`

* Replace `…Mock.called` by `…Mock.callHistory.called`

* Replace `fetchMock.reset` by `fetchMock.hardReset`

* Replace `fetchMock.restore` by `fetchMock.hardReset`

* Replace `fetchMock.resetHistory` by `fetchMock.clearHistory`

* Replace `fetchMock.calls` by `fetchMock.callHistory.calls`

* Replace `fetchMock.flush` by `fetchMock.callHistory.flush`

* Update tests for fetch-mock version 12

See https://www.wheresrhys.co.uk/fetch-mock/docs/Usage/upgrade-guide

* Update stories for fetch-mock version 12

* Remove `overwriteRoutes` option

* Add `fetchMock.spyGlobal()` to storybook

* Remove deprecated `sendAsJson` param

* Replace `fetchMock.hardReset()` by `fetchMock.removeRoutes().clearHistory()`

* Fixup fetch-mock in storybook:

Call `mockGlobal` inside the hook, call `removeRoutes` and `unmockGlobal` on cleanup

Behaviour can be tested by navigating between

https://storybook.dev-overleaf.com/main/?path=/story/editor-ai-error-assistant-compile-log-entries--first-log-entry
https://storybook.dev-overleaf.com/main/?path=/story/editor-ai-error-assistant-compile-log-entries--rate-limited

https://storybook.dev-overleaf.com/main/?path=/story/project-list-notifications--project-invite
https://storybook.dev-overleaf.com/main/?path=/story/project-list-notifications--project-invite-network-error

And clicking the buttons

GitOrigin-RevId: 35611b4430259e4c21c3d819ad18b2e6dab66242
Antoine Clausse il y a 1 an
Parent
commit
b901bb6c75
99 fichiers modifiés avec 727 ajouts et 728 suppressions
  1. 34 78
      package-lock.json
  2. 6 10
      services/web/frontend/stories/file-view/file-view.stories.jsx
  3. 46 55
      services/web/frontend/stories/fixtures/compile.js
  4. 5 4
      services/web/frontend/stories/hooks/use-fetch-mock.tsx
  5. 2 6
      services/web/frontend/stories/modals/create-file/create-file-modal.stories.jsx
  6. 1 5
      services/web/frontend/stories/notification.stories.tsx
  7. 13 17
      services/web/frontend/stories/project-list/helpers/emails.ts
  8. 3 3
      services/web/frontend/stories/project-list/system-messages.stories.tsx
  9. 0 2
      services/web/frontend/stories/settings/helpers/emails.js
  10. 3 3
      services/web/modules/user-activate/test/frontend/js/components/register-form.test.jsx
  11. 7 7
      services/web/modules/user-activate/test/frontend/js/components/user-activate-register.test.jsx
  12. 1 1
      services/web/package.json
  13. 6 0
      services/web/test/frontend/bootstrap.js
  14. 3 3
      services/web/test/frontend/features/chat/components/chat-pane.test.jsx
  15. 50 56
      services/web/test/frontend/features/chat/context/chat-context.test.jsx
  16. 15 11
      services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.jsx
  17. 1 1
      services/web/test/frontend/features/editor-left-menu/components/actions-copy-project.test.jsx
  18. 1 1
      services/web/test/frontend/features/editor-left-menu/components/actions-menu.test.jsx
  19. 7 3
      services/web/test/frontend/features/editor-left-menu/components/actions-word-count.test.jsx
  20. 1 1
      services/web/test/frontend/features/editor-left-menu/components/download-menu.test.jsx
  21. 1 1
      services/web/test/frontend/features/editor-left-menu/components/help-contact-us.test.jsx
  22. 1 1
      services/web/test/frontend/features/editor-left-menu/components/help-menu.test.jsx
  23. 1 1
      services/web/test/frontend/features/editor-left-menu/components/help-show-hotkeys.test.jsx
  24. 1 1
      services/web/test/frontend/features/editor-left-menu/components/settings/settings-auto-close-brackets.test.tsx
  25. 1 1
      services/web/test/frontend/features/editor-left-menu/components/settings/settings-auto-complete.test.tsx
  26. 1 1
      services/web/test/frontend/features/editor-left-menu/components/settings/settings-compiler.test.tsx
  27. 1 1
      services/web/test/frontend/features/editor-left-menu/components/settings/settings-document.test.tsx
  28. 1 1
      services/web/test/frontend/features/editor-left-menu/components/settings/settings-editor-theme.test.tsx
  29. 1 1
      services/web/test/frontend/features/editor-left-menu/components/settings/settings-font-family.test.tsx
  30. 1 1
      services/web/test/frontend/features/editor-left-menu/components/settings/settings-font-size.test.tsx
  31. 1 1
      services/web/test/frontend/features/editor-left-menu/components/settings/settings-image-name.test.tsx
  32. 1 1
      services/web/test/frontend/features/editor-left-menu/components/settings/settings-keybindings.test.tsx
  33. 1 1
      services/web/test/frontend/features/editor-left-menu/components/settings/settings-line-height.test.tsx
  34. 1 1
      services/web/test/frontend/features/editor-left-menu/components/settings/settings-math-preview.test.tsx
  35. 1 1
      services/web/test/frontend/features/editor-left-menu/components/settings/settings-overall-theme.test.tsx
  36. 1 1
      services/web/test/frontend/features/editor-left-menu/components/settings/settings-pdf-viewer.test.tsx
  37. 1 1
      services/web/test/frontend/features/editor-left-menu/components/settings/settings-spell-check-language.test.tsx
  38. 1 1
      services/web/test/frontend/features/editor-left-menu/components/settings/settings-syntax-validation.test.tsx
  39. 1 1
      services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.jsx
  40. 1 1
      services/web/test/frontend/features/file-view/components/file-view-header.test.jsx
  41. 1 1
      services/web/test/frontend/features/file-view/components/file-view-refresh-button.test.jsx
  42. 1 1
      services/web/test/frontend/features/file-view/components/file-view-text.test.jsx
  43. 1 1
      services/web/test/frontend/features/file-view/components/file-view.test.jsx
  44. 1 1
      services/web/test/frontend/features/group-management/components/members-table/unlink-user-modal.test.tsx
  45. 1 1
      services/web/test/frontend/features/project-list/components/load-more.test.tsx
  46. 1 1
      services/web/test/frontend/features/project-list/components/new-project-button.test.tsx
  47. 5 5
      services/web/test/frontend/features/project-list/components/new-project-button/modal-content-new-project-form.test.tsx
  48. 55 54
      services/web/test/frontend/features/project-list/components/notifications.test.tsx
  49. 85 60
      services/web/test/frontend/features/project-list/components/project-list-root.test.tsx
  50. 2 2
      services/web/test/frontend/features/project-list/components/project-search.test.tsx
  51. 10 10
      services/web/test/frontend/features/project-list/components/sidebar/add-affiliation.test.tsx
  52. 17 9
      services/web/test/frontend/features/project-list/components/sidebar/tags-list.test.tsx
  53. 5 5
      services/web/test/frontend/features/project-list/components/system-messages.test.tsx
  54. 5 2
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/archive-project-button.test.tsx
  55. 1 1
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.test.tsx
  56. 5 2
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/copy-project-button.test.tsx
  57. 2 1
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/delete-project-button.test.tsx
  58. 3 2
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/leave-project-button.test.tsx
  59. 3 2
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/rename-project-button.test.tsx
  60. 3 2
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/trash-project-button.test.tsx
  61. 5 2
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/unarchive-project-button.test.tsx
  62. 3 2
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/untrash-project-button.test.tsx
  63. 6 3
      services/web/test/frontend/features/project-list/components/table/cells/inline-tags.test.tsx
  64. 7 7
      services/web/test/frontend/features/project-list/components/table/project-list-table.test.tsx
  65. 1 1
      services/web/test/frontend/features/project-list/components/table/project-tools/project-tools-rename.test.tsx
  66. 4 2
      services/web/test/frontend/features/project-list/components/table/project-tools/rename-project-modal.test.tsx
  67. 1 1
      services/web/test/frontend/features/project-list/helpers/render-with-context.tsx
  68. 27 27
      services/web/test/frontend/features/settings/components/account-info-section.test.tsx
  69. 20 13
      services/web/test/frontend/features/settings/components/emails/add-email-input.test.tsx
  70. 5 5
      services/web/test/frontend/features/settings/components/emails/emails-row.test.tsx
  71. 4 4
      services/web/test/frontend/features/settings/components/emails/emails-section-actions.test.tsx
  72. 31 27
      services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx
  73. 11 13
      services/web/test/frontend/features/settings/components/emails/emails-section-institution-and-role.test.tsx
  74. 2 2
      services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx
  75. 4 4
      services/web/test/frontend/features/settings/components/emails/reconfirmation-info.test.tsx
  76. 1 1
      services/web/test/frontend/features/settings/components/leave/modal-content.test.tsx
  77. 6 6
      services/web/test/frontend/features/settings/components/leave/modal-form.test.tsx
  78. 1 1
      services/web/test/frontend/features/settings/components/leave/modal.test.tsx
  79. 1 1
      services/web/test/frontend/features/settings/components/leavers-survey-alert.test.tsx
  80. 1 1
      services/web/test/frontend/features/settings/components/linking-section.test.tsx
  81. 10 10
      services/web/test/frontend/features/settings/components/password-section.test.tsx
  82. 1 1
      services/web/test/frontend/features/settings/components/security-section.test.tsx
  83. 3 3
      services/web/test/frontend/features/settings/context/sso-context.test.tsx
  84. 24 20
      services/web/test/frontend/features/settings/context/user-email-context.test.tsx
  85. 52 49
      services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx
  86. 2 2
      services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx
  87. 10 7
      services/web/test/frontend/features/subscription/components/dashboard/managed-institutions.test.tsx
  88. 1 1
      services/web/test/frontend/features/subscription/components/dashboard/managed-publishers.test.tsx
  89. 1 1
      services/web/test/frontend/features/subscription/components/dashboard/pause-modal.test.tsx
  90. 4 4
      services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx
  91. 1 1
      services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx
  92. 1 1
      services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx
  93. 1 1
      services/web/test/frontend/features/subscription/components/group-invite/has-individual-recurly-subscription.test.tsx
  94. 1 1
      services/web/test/frontend/features/subscription/components/group-invite/join-group.test.tsx
  95. 1 1
      services/web/test/frontend/features/subscription/helpers/render-with-subscription-dash-context.tsx
  96. 1 1
      services/web/test/frontend/features/word-count-modal/components/word-count-modal.test.jsx
  97. 3 14
      services/web/test/frontend/infrastructure/fetch-json.test.js
  98. 33 33
      services/web/test/frontend/infrastructure/project-snapshot.test.ts
  99. 4 4
      services/web/test/frontend/shared/hooks/use-abort-controller.test.tsx

+ 34 - 78
package-lock.json

@@ -12218,6 +12218,13 @@
         "@types/node": "*"
       }
     },
+    "node_modules/@types/glob-to-regexp": {
+      "version": "0.4.4",
+      "resolved": "https://registry.npmjs.org/@types/glob-to-regexp/-/glob-to-regexp-0.4.4.tgz",
+      "integrity": "sha512-nDKoaKJYbnn1MZxUY0cA1bPmmgZbg0cTq7Rh13d0KWYNOiKbqoR+2d89SnRPszGh7ROzSwZ/GOjZ4jPbmmZ6Eg==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@types/hapi__catbox": {
       "version": "10.2.4",
       "resolved": "https://registry.npmjs.org/@types/hapi__catbox/-/hapi__catbox-10.2.4.tgz",
@@ -22764,45 +22771,6 @@
         "node": ">=4.0.0"
       }
     },
-    "node_modules/fetch-mock": {
-      "version": "9.11.0",
-      "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-9.11.0.tgz",
-      "integrity": "sha512-PG1XUv+x7iag5p/iNHD4/jdpxL9FtVSqRMUQhPab4hVDt80T1MH5ehzVrL2IdXO9Q2iBggArFvPqjUbHFuI58Q==",
-      "dev": true,
-      "dependencies": {
-        "@babel/core": "^7.0.0",
-        "@babel/runtime": "^7.0.0",
-        "core-js": "^3.0.0",
-        "debug": "^4.1.1",
-        "glob-to-regexp": "^0.4.0",
-        "is-subset": "^0.1.1",
-        "lodash.isequal": "^4.5.0",
-        "path-to-regexp": "^2.2.1",
-        "querystring": "^0.2.0",
-        "whatwg-url": "^6.5.0"
-      },
-      "engines": {
-        "node": ">=4.0.0"
-      },
-      "funding": {
-        "type": "charity",
-        "url": "https://www.justgiving.com/refugee-support-europe"
-      },
-      "peerDependencies": {
-        "node-fetch": "*"
-      },
-      "peerDependenciesMeta": {
-        "node-fetch": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/fetch-mock/node_modules/path-to-regexp": {
-      "version": "3.3.0",
-      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz",
-      "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==",
-      "dev": true
-    },
     "node_modules/figures": {
       "version": "3.2.0",
       "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
@@ -26431,12 +26399,6 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
-    "node_modules/is-subset": {
-      "version": "0.1.1",
-      "resolved": "https://registry.npmjs.org/is-subset/-/is-subset-0.1.1.tgz",
-      "integrity": "sha1-ilkRfZMt4d4A8kX83TnOQ/HpOaY=",
-      "dev": true
-    },
     "node_modules/is-symbol": {
       "version": "1.0.4",
       "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz",
@@ -28433,12 +28395,6 @@
       "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
       "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w="
     },
-    "node_modules/lodash.sortby": {
-      "version": "4.7.0",
-      "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
-      "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=",
-      "dev": true
-    },
     "node_modules/lodash.support": {
       "version": "2.4.1",
       "resolved": "https://registry.npmjs.org/lodash.support/-/lodash.support-2.4.1.tgz",
@@ -35391,6 +35347,16 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/regexparam": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-3.0.0.tgz",
+      "integrity": "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/regexpp": {
       "version": "3.2.0",
       "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
@@ -39423,15 +39389,6 @@
         "node": ">= 4.0.0"
       }
     },
-    "node_modules/tr46": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
-      "integrity": "sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=",
-      "dev": true,
-      "dependencies": {
-        "punycode": "^2.1.0"
-      }
-    },
     "node_modules/traverse": {
       "version": "0.6.9",
       "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.9.tgz",
@@ -41219,23 +41176,6 @@
         "node": ">=12"
       }
     },
-    "node_modules/whatwg-url": {
-      "version": "6.5.0",
-      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-6.5.0.tgz",
-      "integrity": "sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==",
-      "dev": true,
-      "dependencies": {
-        "lodash.sortby": "^4.7.0",
-        "tr46": "^1.0.1",
-        "webidl-conversions": "^4.0.2"
-      }
-    },
-    "node_modules/whatwg-url/node_modules/webidl-conversions": {
-      "version": "4.0.2",
-      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
-      "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==",
-      "dev": true
-    },
     "node_modules/which": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -44521,7 +44461,7 @@
         "esmock": "^2.6.7",
         "events": "^3.3.0",
         "fake-indexeddb": "^6.0.0",
-        "fetch-mock": "^9.10.2",
+        "fetch-mock": "^12.5.2",
         "formik": "^2.2.9",
         "fuse.js": "^3.0.0",
         "glob": "^7.1.6",
@@ -45294,6 +45234,22 @@
       "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==",
       "dev": true
     },
+    "services/web/node_modules/fetch-mock": {
+      "version": "12.5.2",
+      "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-12.5.2.tgz",
+      "integrity": "sha512-b5KGDFmdmado2MPQjZl6ix3dAG3iwCitb0XQwN72y2s9VnWZ3ObaGNy+bkpm1390foiLDybdJ7yjRGKD36kATw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/glob-to-regexp": "^0.4.4",
+        "dequal": "^2.0.3",
+        "glob-to-regexp": "^0.4.1",
+        "regexparam": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=18.11.0"
+      }
+    },
     "services/web/node_modules/google-auth-library": {
       "version": "8.7.0",
       "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-8.7.0.tgz",

+ 6 - 10
services/web/frontend/stories/file-view/file-view.stories.jsx

@@ -158,11 +158,9 @@ ImageFile.args = {
 
 export const TextFile = args => {
   useFetchMock(fetchMock =>
-    setupFetchMock(fetchMock).get(
-      'express:/project/:project_id/blob/:hash',
-      { body: bodies.text },
-      { overwriteRoutes: true }
-    )
+    setupFetchMock(fetchMock).get('express:/project/:project_id/blob/:hash', {
+      body: bodies.text,
+    })
   )
   return <FileView {...args} />
 }
@@ -180,11 +178,9 @@ TextFile.args = {
 
 export const UploadedFile = args => {
   useFetchMock(fetchMock =>
-    setupFetchMock(fetchMock).head(
-      'express:/project/:project_id/blob/:hash',
-      { status: 500 },
-      { overwriteRoutes: true }
-    )
+    setupFetchMock(fetchMock).head('express:/project/:project_id/blob/:hash', {
+      status: 500,
+    })
   )
   return <FileView {...args} />
 }

+ 46 - 55
services/web/frontend/stories/fixtures/compile.js

@@ -58,7 +58,7 @@ export const mockCompile = (fetchMock, delay = 1000) =>
         outputFiles: cloneDeep(outputFiles),
       },
     },
-    { delay, overwriteRoutes: true }
+    { delay }
   )
 
 export const mockCompileError = (fetchMock, status = 'success', delay = 1000) =>
@@ -91,27 +91,24 @@ export const mockCompileValidationIssues = (
         },
       }
     },
-    { delay, overwriteRoutes: true }
+    { delay }
   )
 
 export const mockClearCache = fetchMock =>
   fetchMock.delete('express:/project/:projectId/output', 204, {
     delay: 1000,
-    overwriteRoutes: true,
   })
 
 export const mockBuildFile = fetchMock =>
-  fetchMock.get(
-    'express:/build/:file',
-    (url, options, request) => {
-      const { pathname } = new URL(url, 'https://example.com')
+  fetchMock.get('express:/build/:file', (url, options, request) => {
+    const { pathname } = new URL(url, 'https://example.com')
 
-      switch (pathname) {
-        case '/build/output.blg':
-          return 'This is BibTeX, Version 4.0' // FIXME
+    switch (pathname) {
+      case '/build/output.blg':
+        return 'This is BibTeX, Version 4.0' // FIXME
 
-        case '/build/output.log':
-          return `
+      case '/build/output.log':
+        return `
 The LaTeX compiler output
   * With a lot of details
 
@@ -134,31 +131,29 @@ LaTeX Font Info:    External font \`cmex10' loaded for size
 
 `
 
-        case '/build/output.pdf':
-          return new Promise(resolve => {
-            const xhr = new XMLHttpRequest()
-            xhr.addEventListener('load', () => {
-              resolve({
-                status: 200,
-                headers: {
-                  'Content-Length': xhr.getResponseHeader('Content-Length'),
-                  'Content-Type': xhr.getResponseHeader('Content-Type'),
-                },
-                body: xhr.response,
-              })
+      case '/build/output.pdf':
+        return new Promise(resolve => {
+          const xhr = new XMLHttpRequest()
+          xhr.addEventListener('load', () => {
+            resolve({
+              status: 200,
+              headers: {
+                'Content-Length': xhr.getResponseHeader('Content-Length'),
+                'Content-Type': xhr.getResponseHeader('Content-Type'),
+              },
+              body: xhr.response,
             })
-            xhr.open('GET', examplePdf)
-            xhr.responseType = 'arraybuffer'
-            xhr.send()
           })
+          xhr.open('GET', examplePdf)
+          xhr.responseType = 'arraybuffer'
+          xhr.send()
+        })
 
-        default:
-          console.log(pathname)
-          return 404
-      }
-    },
-    { sendAsJson: false, overwriteRoutes: true }
-  )
+      default:
+        console.log(pathname)
+        return 404
+    }
+  })
 
 const mockHighlights = [
   {
@@ -195,29 +190,25 @@ export const mockEventTracking = fetchMock =>
   fetchMock.get('express:/event/:event', 204)
 
 export const mockValidPdf = fetchMock =>
-  fetchMock.get(
-    'express:/build/output.pdf',
-    (url, options, request) => {
-      return new Promise(resolve => {
-        const xhr = new XMLHttpRequest()
-        xhr.addEventListener('load', () => {
-          resolve({
-            status: 200,
-            headers: {
-              'Content-Length': xhr.getResponseHeader('Content-Length'),
-              'Content-Type': xhr.getResponseHeader('Content-Type'),
-              'Accept-Ranges': 'bytes',
-            },
-            body: xhr.response,
-          })
+  fetchMock.get('express:/build/output.pdf', (url, options, request) => {
+    return new Promise(resolve => {
+      const xhr = new XMLHttpRequest()
+      xhr.addEventListener('load', () => {
+        resolve({
+          status: 200,
+          headers: {
+            'Content-Length': xhr.getResponseHeader('Content-Length'),
+            'Content-Type': xhr.getResponseHeader('Content-Type'),
+            'Accept-Ranges': 'bytes',
+          },
+          body: xhr.response,
         })
-        xhr.open('GET', examplePdf)
-        xhr.responseType = 'arraybuffer'
-        xhr.send()
       })
-    },
-    { sendAsJson: false, overwriteRoutes: true }
-  )
+      xhr.open('GET', examplePdf)
+      xhr.responseType = 'arraybuffer'
+      xhr.send()
+    })
+  })
 
 export const mockSynctex = fetchMock =>
   fetchMock

+ 5 - 4
services/web/frontend/stories/hooks/use-fetch-mock.tsx

@@ -1,18 +1,19 @@
 import { useLayoutEffect } from 'react'
 import fetchMock from 'fetch-mock'
-fetchMock.config.fallbackToNetwork = true
 
 /**
- * Run callback to mock fetch routes, call restore() when unmounted
+ * Run callback to mock fetch routes, call removeRoutes() and unmockGlobal() when unmounted
  */
 export default function useFetchMock(
   callback: (value: typeof fetchMock) => void
 ) {
+  fetchMock.mockGlobal()
+
   useLayoutEffect(() => {
     callback(fetchMock)
-
     return () => {
-      fetchMock.restore()
+      fetchMock.removeRoutes()
+      fetchMock.unmockGlobal()
     }
   }, [callback])
 }

+ 2 - 6
services/web/frontend/stories/modals/create-file/create-file-modal.stories.jsx

@@ -37,9 +37,7 @@ export const ErrorImportingFileFromExternalURL = args => {
   useFetchMock(fetchMock => {
     mockCreateFileModalFetch(fetchMock)
 
-    fetchMock.post('express:/project/:projectId/linked_file', 500, {
-      overwriteRoutes: true,
-    })
+    fetchMock.post('express:/project/:projectId/linked_file', 500)
   })
 
   getMeta('ol-ExposedSettings').hasLinkUrlFeature = true
@@ -52,9 +50,7 @@ export const ErrorImportingFileFromReferenceProvider = args => {
   useFetchMock(fetchMock => {
     mockCreateFileModalFetch(fetchMock)
 
-    fetchMock.post('express:/project/:projectId/linked_file', 500, {
-      overwriteRoutes: true,
-    })
+    fetchMock.post('express:/project/:projectId/linked_file', 500)
   })
 
   return <FileTreeModalCreateFile {...args} />

+ 1 - 5
services/web/frontend/stories/notification.stories.tsx

@@ -315,11 +315,7 @@ export const OverlayedWithCustomClass = (args: Args) => {
 
 export const SuccessFlow = (args: Args) => {
   console.log('.....render')
-  fetchMock.post(
-    'express:/test-success',
-    { status: 200 },
-    { delay: 250, overwriteRoutes: true }
-  )
+  fetchMock.post('express:/test-success', { status: 200 }, { delay: 250 })
 
   const { isLoading, isSuccess, runAsync } = useAsync()
   function handleClick() {

+ 13 - 17
services/web/frontend/stories/project-list/helpers/emails.ts

@@ -1,5 +1,5 @@
 import { merge, cloneDeep } from 'lodash'
-import { FetchMockStatic } from 'fetch-mock'
+import { type FetchMock } from 'fetch-mock'
 import { UserEmailData } from '../../../../types/user-email'
 import {
   Institution,
@@ -32,7 +32,7 @@ export const fakeReconfirmationUsersData = {
   default: false,
 } as DeepReadonly<UserEmailData>
 
-export function defaultSetupMocks(fetchMock: FetchMockStatic) {
+export function defaultSetupMocks(fetchMock: FetchMock) {
   // at least one project is required to show some notifications
   const projects = [{}] as Project[]
   fetchMock.post(/\/api\/project/, {
@@ -54,7 +54,7 @@ export function setDefaultMeta() {
   window.metaAttributesCache.set('ol-userEmails', [])
 }
 
-export function errorsMocks(fetchMock: FetchMockStatic) {
+export function errorsMocks(fetchMock: FetchMock) {
   defaultSetupMocks(fetchMock)
   fetchMock.post(/\/user\/emails\/*/, 500, { delay: MOCK_DELAY })
   fetchMock.post(
@@ -74,7 +74,7 @@ export function setInstitutionMeta(institutionData: Partial<Institution>) {
   ])
 }
 
-export function institutionSetupMocks(fetchMock: FetchMockStatic) {
+export function institutionSetupMocks(fetchMock: FetchMock) {
   defaultSetupMocks(fetchMock)
   fetchMock.delete(/\/notifications\/*/, 200, { delay: MOCK_DELAY })
 }
@@ -84,7 +84,7 @@ export function setCommonMeta(notificationData: DeepPartial<Notification>) {
   window.metaAttributesCache.set('ol-notifications', [notificationData])
 }
 
-export function commonSetupMocks(fetchMock: FetchMockStatic) {
+export function commonSetupMocks(fetchMock: FetchMock) {
   defaultSetupMocks(fetchMock)
   fetchMock.post(
     /\/project\/[A-Za-z0-9]+\/invite\/token\/[A-Za-z0-9]+\/accept/,
@@ -98,7 +98,7 @@ export function setReconfirmationMeta() {
   window.metaAttributesCache.set('ol-userEmails', [fakeReconfirmationUsersData])
 }
 
-export function reconfirmationSetupMocks(fetchMock: FetchMockStatic) {
+export function reconfirmationSetupMocks(fetchMock: FetchMock) {
   defaultSetupMocks(fetchMock)
   fetchMock.post(/\/user\/emails\/resend_confirmation/, 200, {
     delay: MOCK_DELAY,
@@ -113,19 +113,15 @@ export function setReconfirmAffiliationMeta() {
   )
 }
 
-export function reconfirmAffiliationSetupMocks(fetchMock: FetchMockStatic) {
+export function reconfirmAffiliationSetupMocks(fetchMock: FetchMock) {
   defaultSetupMocks(fetchMock)
-  fetchMock.post(
-    /\/api\/project/,
-    {
-      status: 200,
-      body: {
-        projects: [{}],
-        totalSize: 0,
-      },
+  fetchMock.post(/\/api\/project/, {
+    status: 200,
+    body: {
+      projects: [{}],
+      totalSize: 0,
     },
-    { overwriteRoutes: true }
-  )
+  })
   fetchMock.post(/\/user\/emails\/send-reconfirmation/, 200, {
     delay: MOCK_DELAY,
   })

+ 3 - 3
services/web/frontend/stories/project-list/system-messages.stories.tsx

@@ -1,9 +1,9 @@
 import SystemMessages from '@/shared/components/system-messages'
 import useFetchMock from '../hooks/use-fetch-mock'
-import { FetchMockStatic } from 'fetch-mock'
+import { type FetchMock } from 'fetch-mock'
 
 export const SystemMessage = (args: any) => {
-  useFetchMock((fetchMock: FetchMockStatic) => {
+  useFetchMock((fetchMock: FetchMock) => {
     fetchMock.get(/\/system\/messages/, [
       {
         _id: 1,
@@ -23,7 +23,7 @@ export const SystemMessage = (args: any) => {
 }
 
 export const TranslationMessage = (args: any) => {
-  useFetchMock((fetchMock: FetchMockStatic) => {
+  useFetchMock((fetchMock: FetchMock) => {
     fetchMock.get(/\/system\/messages/, [])
   })
 

+ 0 - 2
services/web/frontend/stories/settings/helpers/emails.js

@@ -174,7 +174,6 @@ export function reconfirmationSetupMocks(fetchMock) {
   defaultSetupMocks(fetchMock)
   fetchMock.get(/\/user\/emails/, fakeReconfirmationUsersData, {
     delay: MOCK_DELAY,
-    overwriteRoutes: true,
   })
 }
 
@@ -186,7 +185,6 @@ export function emailLimitSetupMocks(fetchMock) {
   defaultSetupMocks(fetchMock)
   fetchMock.get(/\/user\/emails/, userData, {
     delay: MOCK_DELAY,
-    overwriteRoutes: true,
   })
 }
 

+ 3 - 3
services/web/modules/user-activate/test/frontend/js/components/register-form.test.jsx

@@ -6,10 +6,10 @@ import RegisterForm from '../../../../frontend/js/components/register-form'
 
 describe('RegisterForm', function () {
   beforeEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
   it('should render the register form', async function () {
     const setRegistrationSuccessStub = sinon.stub()
@@ -57,6 +57,6 @@ describe('RegisterForm', function () {
     const registerButton = screen.getByRole('button', { name: /register/i })
     fireEvent.change(registerInput, { target: { value: email } })
     fireEvent.click(registerButton)
-    expect(registerMock.called()).to.be.true
+    expect(registerMock.callHistory.called()).to.be.true
   })
 })

+ 7 - 7
services/web/modules/user-activate/test/frontend/js/components/user-activate-register.test.jsx

@@ -5,10 +5,10 @@ import UserActivateRegister from '../../../../frontend/js/components/user-activa
 
 describe('UserActivateRegister', function () {
   beforeEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
   it('should display the error message', async function () {
     const email = 'abc@gmail.com'
@@ -23,7 +23,7 @@ describe('UserActivateRegister', function () {
     fireEvent.change(registerInput, { target: { value: email } })
     fireEvent.click(registerButton)
 
-    expect(registerMock.called()).to.be.true
+    expect(registerMock.callHistory.called()).to.be.true
     await screen.findByText('Sorry, an error occured', { exact: false })
   })
 
@@ -44,7 +44,7 @@ describe('UserActivateRegister', function () {
     fireEvent.change(registerInput, { target: { value: email } })
     fireEvent.click(registerButton)
 
-    expect(registerMock.called()).to.be.true
+    expect(registerMock.callHistory.called()).to.be.true
     await screen.findByText(
       "We've sent out welcome emails to the registered users."
     )
@@ -78,7 +78,7 @@ describe('UserActivateRegister', function () {
     fireEvent.change(registerInput, { target: { value: email } })
     fireEvent.click(registerButton)
 
-    expect(registerMock.called()).to.be.true
+    expect(registerMock.callHistory.called()).to.be.true
     await screen.findByText('abc@gmail.com')
     await screen.findByText('def@gmail.com')
   })
@@ -103,7 +103,7 @@ describe('UserActivateRegister', function () {
     fireEvent.change(registerInput, { target: { value: email } })
     fireEvent.click(registerButton)
 
-    expect(registerMock.called()).to.be.true
+    expect(registerMock.callHistory.called()).to.be.true
     await screen.findByText('abc@')
     await screen.findByText('def@')
   })
@@ -133,7 +133,7 @@ describe('UserActivateRegister', function () {
     fireEvent.change(registerInput, { target: { value: email } })
     fireEvent.click(registerButton)
 
-    expect(registerMock.called()).to.be.true
+    expect(registerMock.callHistory.called()).to.be.true
     await screen.findByText('abc@gmail.com')
     await screen.findByText('def@')
   })

+ 1 - 1
services/web/package.json

@@ -295,7 +295,7 @@
     "esmock": "^2.6.7",
     "events": "^3.3.0",
     "fake-indexeddb": "^6.0.0",
-    "fetch-mock": "^9.10.2",
+    "fetch-mock": "^12.5.2",
     "formik": "^2.2.9",
     "fuse.js": "^3.0.0",
     "glob": "^7.1.6",

+ 6 - 0
services/web/test/frontend/bootstrap.js

@@ -98,3 +98,9 @@ globalThis.DOMParser = window.DOMParser
 
 // Polyfill for IndexedDB
 require('fake-indexeddb/auto')
+
+const fetchMock = require('fetch-mock').default
+
+fetchMock.spyGlobal()
+fetchMock.config.fetch = global.fetch
+fetchMock.config.Response = fetch.Response

+ 3 - 3
services/web/test/frontend/features/chat/components/chat-pane.test.jsx

@@ -26,7 +26,7 @@ describe('<ChatPane />', function () {
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   const testMessages = [
@@ -45,7 +45,7 @@ describe('<ChatPane />', function () {
   ]
 
   beforeEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
     cleanUpContext()
 
     stubMathJax()
@@ -73,7 +73,7 @@ describe('<ChatPane />', function () {
     await screen.findByText('Try again')
 
     // bring chat back up
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
     fetchMock.get(/messages/, [])
 
     const reconnectButton = screen.getByRole('button', {

+ 50 - 56
services/web/test/frontend/features/chat/context/chat-context.test.jsx

@@ -23,7 +23,7 @@ describe('ChatContext', function () {
   const uuidValue = '00000000-0000-0000-0000-000000000000'
 
   beforeEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
     cleanUpContext()
 
     stubMathJax()
@@ -43,14 +43,15 @@ describe('ChatContext', function () {
   describe('socket connection', function () {
     beforeEach(function () {
       // Mock GET messages to return no messages
-      fetchMock.get('express:/project/:projectId/messages', [])
+      // FIXME?
+      // fetchMock.get('express:/project/:projectId/messages', [])
 
       // Mock POST new message to return 200
       fetchMock.post('express:/project/:projectId/messages', 200)
     })
 
     afterEach(function () {
-      fetchMock.reset()
+      fetchMock.removeRoutes().clearHistory()
     })
 
     it('subscribes when mounted', function () {
@@ -106,22 +107,18 @@ describe('ChatContext', function () {
         socket,
       })
 
-      fetchMock.get(
-        'express:/project/:projectId/messages',
-        [
-          {
-            id: 'msg_1',
-            content: 'new message',
-            timestamp: Date.now(),
-            user: {
-              id: 'another_fake_user',
-              first_name: 'another_fake_user_first_name',
-              email: 'another_fake@example.com',
-            },
+      fetchMock.get('express:/project/:projectId/messages', [
+        {
+          id: 'msg_1',
+          content: 'new message',
+          timestamp: Date.now(),
+          user: {
+            id: 'another_fake_user',
+            first_name: 'another_fake_user_first_name',
+            email: 'another_fake@example.com',
           },
-        ],
-        { overwriteRoutes: true }
-      )
+        },
+      ])
 
       // Mock message being received from another user
       socket.emitToClient('new-chat-message', {
@@ -157,22 +154,18 @@ describe('ChatContext', function () {
         socket,
       })
 
-      fetchMock.get(
-        'express:/project/:projectId/messages',
-        [
-          {
-            id: 'msg_1',
-            content: 'new message',
-            timestamp: Date.now(),
-            user: {
-              id: 'another_fake_user',
-              first_name: 'another_fake_user_first_name',
-              email: 'another_fake@example.com',
-            },
+      fetchMock.get('express:/project/:projectId/messages', [
+        {
+          id: 'msg_1',
+          content: 'new message',
+          timestamp: Date.now(),
+          user: {
+            id: 'another_fake_user',
+            first_name: 'another_fake_user_first_name',
+            email: 'another_fake@example.com',
           },
-        ],
-        { overwriteRoutes: true }
-      )
+        },
+      ])
 
       // Wait until initial messages have loaded
       result.current.loadInitialMessages()
@@ -320,11 +313,13 @@ describe('ChatContext', function () {
 
       // Calling a second time won't do anything
       result.current.loadInitialMessages()
-      expect(fetchMock.calls()).to.have.lengthOf(1)
+      expect(
+        fetchMock.callHistory.calls('express:/project/:projectId/messages')
+      ).to.have.lengthOf(1)
     })
 
     it('provides an error on failure', async function () {
-      fetchMock.reset()
+      fetchMock.removeRoutes().clearHistory()
       fetchMock.get('express:/project/:projectId/messages', 500)
       const { result, waitForNextUpdate } = renderChatContextHook({})
 
@@ -369,18 +364,14 @@ describe('ChatContext', function () {
           // Resolve a full "page" of messages (50)
           createMessages(50, user, new Date('2021-03-04T10:00:00').getTime())
         )
-        .getOnce(
-          'express:/project/:projectId/messages',
-          [
-            {
-              id: 'msg_51',
-              content: 'message from second page',
-              user,
-              timestamp: new Date('2021-03-04T11:00:00').getTime(),
-            },
-          ],
-          { overwriteRoutes: false }
-        )
+        .getOnce('express:/project/:projectId/messages', [
+          {
+            id: 'msg_51',
+            content: 'message from second page',
+            user,
+            timestamp: new Date('2021-03-04T11:00:00').getTime(),
+          },
+        ])
 
       const { result, waitForNextUpdate } = renderChatContextHook({})
 
@@ -422,7 +413,9 @@ describe('ChatContext', function () {
       result.current.loadMoreMessages()
 
       expect(result.current.atEnd).to.be.true
-      expect(fetchMock.calls()).to.have.lengthOf(1)
+      expect(
+        fetchMock.callHistory.calls('express:/project/:projectId/messages')
+      ).to.have.lengthOf(1)
     })
 
     it('handles socket messages while loading', async function () {
@@ -479,7 +472,7 @@ describe('ChatContext', function () {
     })
 
     it('provides an error on failures', async function () {
-      fetchMock.reset()
+      fetchMock.removeRoutes().clearHistory()
       fetchMock.get('express:/project/:projectId/messages', 500)
       const { result, waitForNextUpdate } = renderChatContextHook({})
 
@@ -515,10 +508,11 @@ describe('ChatContext', function () {
 
       result.current.sendMessage('sent message')
 
-      const [, { body }] = fetchMock.lastCall(
-        'express:/project/:projectId/messages',
-        'POST'
-      )
+      const {
+        options: { body },
+      } = fetchMock.callHistory
+        .calls('express:/project/:projectId/messages', { method: 'POST' })
+        .at(-1)
       expect(JSON.parse(body)).to.deep.include({ content: 'sent message' })
     })
 
@@ -529,14 +523,14 @@ describe('ChatContext', function () {
 
       expect(result.current.messages).to.be.empty
       expect(
-        fetchMock.called('express:/project/:projectId/messages', {
+        fetchMock.callHistory.called('express:/project/:projectId/messages', {
           method: 'post',
         })
       ).to.be.false
     })
 
     it('provides an error on failure', async function () {
-      fetchMock.reset()
+      fetchMock.removeRoutes().clearHistory()
       fetchMock
         .get('express:/project/:projectId/messages', [])
         .postOnce('express:/project/:projectId/messages', 500)
@@ -614,7 +608,7 @@ function createMessages(number, user, timestamp = Date.now()) {
  * Get query param by key from the last fetchMock response
  */
 function getLastFetchMockQueryParam(key) {
-  const url = fetchMock.lastUrl()
+  const { url } = fetchMock.callHistory.calls().at(-1)
   const { searchParams } = new URL(url, 'https://www.overleaf.com')
   return searchParams.get(key)
 }

+ 15 - 11
services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.jsx

@@ -7,11 +7,11 @@ import { renderWithEditorContext } from '../../../helpers/render-with-context'
 
 describe('<EditorCloneProjectModalWrapper />', function () {
   beforeEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   after(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   const project = {
@@ -78,19 +78,23 @@ describe('<EditorCloneProjectModalWrapper />', function () {
     fireEvent.click(submitButton)
     expect(submitButton.disabled).to.be.true
 
-    await fetchMock.flush(true)
-    expect(fetchMock.done()).to.be.true
-    const [url, options] = fetchMock.lastCall(
-      'express:/project/:projectId/clone'
+    await fetchMock.callHistory.flush(true)
+    expect(fetchMock.callHistory.done()).to.be.true
+    const { url, options } = fetchMock.callHistory
+      .calls('express:/project/:projectId/clone')
+      .at(-1)
+    expect(url).to.equal(
+      'https://www.test-overleaf.com/project/project-1/clone'
     )
-    expect(url).to.equal('/project/project-1/clone')
 
     expect(JSON.parse(options.body)).to.deep.equal({
       projectName: 'A Cloned Project',
       tags: [],
     })
 
-    expect(openProject).to.be.calledOnce
+    await waitFor(() => {
+      expect(openProject).to.be.calledOnce
+    })
 
     const errorMessage = screen.queryByText('Sorry, something went wrong')
     expect(errorMessage).to.be.null
@@ -129,7 +133,7 @@ describe('<EditorCloneProjectModalWrapper />', function () {
 
     fireEvent.click(button)
 
-    expect(fetchMock.done(matcher)).to.be.true
+    expect(fetchMock.callHistory.done(matcher)).to.be.true
     expect(openProject).not.to.be.called
 
     await screen.findByText('Sorry, something went wrong')
@@ -165,9 +169,9 @@ describe('<EditorCloneProjectModalWrapper />', function () {
     expect(cancelButton.disabled).to.be.false
 
     fireEvent.click(button)
-    await fetchMock.flush(true)
+    await fetchMock.callHistory.flush(true)
 
-    expect(fetchMock.done(matcher)).to.be.true
+    expect(fetchMock.callHistory.done(matcher)).to.be.true
     expect(openProject).not.to.be.called
 
     await screen.findByText('There was an error!')

+ 1 - 1
services/web/test/frontend/features/editor-left-menu/components/actions-copy-project.test.jsx

@@ -20,7 +20,7 @@ describe('<ActionsCopyProject />', function () {
 
   afterEach(function () {
     this.locationStub.restore()
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('shows correct modal when clicked', async function () {

+ 1 - 1
services/web/test/frontend/features/editor-left-menu/components/actions-menu.test.jsx

@@ -21,7 +21,7 @@ describe('<ActionsMenu />', function () {
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('shows correct menu for non-anonymous users', async function () {

+ 7 - 3
services/web/test/frontend/features/editor-left-menu/components/actions-word-count.test.jsx

@@ -6,7 +6,7 @@ import { renderWithEditorContext } from '../../../helpers/render-with-context'
 
 describe('<ActionsWordCount />', function () {
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('shows correct modal when clicked after document is compiled', async function () {
@@ -51,11 +51,15 @@ describe('<ActionsWordCount />', function () {
     // when loading, we don't render the "Word Count" as button yet
     expect(screen.queryByRole('button', { name: 'Word Count' })).to.equal(null)
 
-    await waitFor(() => expect(fetchMock.called(compileEndpoint)).to.be.true)
+    await waitFor(
+      () => expect(fetchMock.callHistory.called(compileEndpoint)).to.be.true
+    )
 
     const button = await screen.findByRole('button', { name: 'Word Count' })
     button.click()
 
-    await waitFor(() => expect(fetchMock.called(wordcountEndpoint)).to.be.true)
+    await waitFor(
+      () => expect(fetchMock.callHistory.called(wordcountEndpoint)).to.be.true
+    )
   })
 })

+ 1 - 1
services/web/test/frontend/features/editor-left-menu/components/download-menu.test.jsx

@@ -6,7 +6,7 @@ import { renderWithEditorContext } from '../../../helpers/render-with-context'
 
 describe('<DownloadMenu />', function () {
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('shows download links with correct url', async function () {

+ 1 - 1
services/web/test/frontend/features/editor-left-menu/components/help-contact-us.test.jsx

@@ -14,7 +14,7 @@ describe('<HelpContactUs />', function () {
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('open contact us modal when clicked', function () {

+ 1 - 1
services/web/test/frontend/features/editor-left-menu/components/help-menu.test.jsx

@@ -14,7 +14,7 @@ describe('<HelpMenu />', function () {
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('shows correct menu if `showSupport` is `true`', function () {

+ 1 - 1
services/web/test/frontend/features/editor-left-menu/components/help-show-hotkeys.test.jsx

@@ -6,7 +6,7 @@ import fetchMock from 'fetch-mock'
 
 describe('<HelpShowHotkeys />', function () {
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('open hotkeys modal when clicked', function () {

+ 1 - 1
services/web/test/frontend/features/editor-left-menu/components/settings/settings-auto-close-brackets.test.tsx

@@ -7,7 +7,7 @@ import { EditorProviders } from '../../../../helpers/editor-providers'
 
 describe('<SettingsAutoCloseBrackets />', function () {
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('shows correct menu', async function () {

+ 1 - 1
services/web/test/frontend/features/editor-left-menu/components/settings/settings-auto-complete.test.tsx

@@ -7,7 +7,7 @@ import { EditorProviders } from '../../../../helpers/editor-providers'
 
 describe('<SettingsAutoComplete />', function () {
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('shows correct menu', async function () {

+ 1 - 1
services/web/test/frontend/features/editor-left-menu/components/settings/settings-compiler.test.tsx

@@ -7,7 +7,7 @@ import { EditorProviders } from '../../../../helpers/editor-providers'
 
 describe('<SettingsCompiler />', function () {
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('shows correct menu', async function () {

+ 1 - 1
services/web/test/frontend/features/editor-left-menu/components/settings/settings-document.test.tsx

@@ -30,7 +30,7 @@ describe('<SettingsDocument />', function () {
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
     window.metaAttributesCache.set('ol-ExposedSettings', originalSettings)
   })
 

+ 1 - 1
services/web/test/frontend/features/editor-left-menu/components/settings/settings-editor-theme.test.tsx

@@ -16,7 +16,7 @@ describe('<SettingsEditorTheme />', function () {
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('shows correct menu', async function () {

+ 1 - 1
services/web/test/frontend/features/editor-left-menu/components/settings/settings-font-family.test.tsx

@@ -7,7 +7,7 @@ import { EditorProviders } from '../../../../helpers/editor-providers'
 
 describe('<SettingsFontFamily />', function () {
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('shows correct menu', async function () {

+ 1 - 1
services/web/test/frontend/features/editor-left-menu/components/settings/settings-font-size.test.tsx

@@ -9,7 +9,7 @@ describe('<SettingsFontSize />', function () {
   const sizes = ['10', '11', '12', '13', '14', '16', '18', '20', '22', '24']
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('shows correct menu', async function () {

+ 1 - 1
services/web/test/frontend/features/editor-left-menu/components/settings/settings-image-name.test.tsx

@@ -23,7 +23,7 @@ describe('<SettingsImageName />', function () {
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('shows correct menu', async function () {

+ 1 - 1
services/web/test/frontend/features/editor-left-menu/components/settings/settings-keybindings.test.tsx

@@ -7,7 +7,7 @@ import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/e
 
 describe('<SettingsKeybindings />', function () {
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('shows correct menu', async function () {

+ 1 - 1
services/web/test/frontend/features/editor-left-menu/components/settings/settings-line-height.test.tsx

@@ -7,7 +7,7 @@ import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/e
 
 describe('<SettingsLineHeight />', function () {
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('shows correct menu', async function () {

+ 1 - 1
services/web/test/frontend/features/editor-left-menu/components/settings/settings-math-preview.test.tsx

@@ -7,7 +7,7 @@ import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/e
 
 describe('<SettingsMathPreview />', function () {
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('shows correct menu', async function () {

+ 1 - 1
services/web/test/frontend/features/editor-left-menu/components/settings/settings-overall-theme.test.tsx

@@ -32,7 +32,7 @@ describe('<SettingsOverallTheme />', function () {
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('shows correct menu', async function () {

+ 1 - 1
services/web/test/frontend/features/editor-left-menu/components/settings/settings-pdf-viewer.test.tsx

@@ -7,7 +7,7 @@ import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/e
 
 describe('<SettingsPdfViewer />', function () {
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('shows correct menu', async function () {

+ 1 - 1
services/web/test/frontend/features/editor-left-menu/components/settings/settings-spell-check-language.test.tsx

@@ -25,7 +25,7 @@ describe('<SettingsSpellCheckLanguage />', function () {
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('shows correct menu', async function () {

+ 1 - 1
services/web/test/frontend/features/editor-left-menu/components/settings/settings-syntax-validation.test.tsx

@@ -7,7 +7,7 @@ import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/e
 
 describe('<SettingsSyntaxValidation />', function () {
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('shows correct menu', async function () {

+ 1 - 1
services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.jsx

@@ -22,7 +22,7 @@ describe('<LayoutDropdownButton />', function () {
   afterEach(function () {
     openStub.restore()
     sendMBSpy.restore()
-    fetchMock.restore()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('should mark current layout option as selected', async function () {

+ 1 - 1
services/web/test/frontend/features/file-view/components/file-view-header.test.jsx

@@ -39,7 +39,7 @@ describe('<FileViewHeader/>', function () {
   }
 
   beforeEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   describe('header text', function () {

+ 1 - 1
services/web/test/frontend/features/file-view/components/file-view-refresh-button.test.jsx

@@ -23,7 +23,7 @@ describe('<FileViewRefreshButton />', function () {
   }
 
   beforeEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   // eslint-disable-next-line mocha/no-skipped-tests

+ 1 - 1
services/web/test/frontend/features/file-view/components/file-view-text.test.jsx

@@ -19,7 +19,7 @@ describe('<FileViewText/>', function () {
   }
 
   beforeEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('renders a text view', async function () {

+ 1 - 1
services/web/test/frontend/features/file-view/components/file-view.test.jsx

@@ -32,7 +32,7 @@ describe('<FileView/>', function () {
   }
 
   beforeEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   describe('for a text file', function () {

+ 1 - 1
services/web/test/frontend/features/group-management/components/members-table/unlink-user-modal.test.tsx

@@ -31,7 +31,7 @@ describe('<UnlinkUserModal />', function () {
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('displays the modal', async function () {

+ 1 - 1
services/web/test/frontend/features/project-list/components/load-more.test.tsx

@@ -11,7 +11,7 @@ import { renderWithProjectListContext } from '../helpers/render-with-context'
 
 describe('<LoadMore />', function () {
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('renders on a project list longer than 40', async function () {

+ 1 - 1
services/web/test/frontend/features/project-list/components/new-project-button.test.tsx

@@ -7,7 +7,7 @@ import getMeta from '@/utils/meta'
 
 describe('<NewProjectButton />', function () {
   beforeEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   describe('for every user (affiliated and non-affiliated)', function () {

+ 5 - 5
services/web/test/frontend/features/project-list/components/new-project-button/modal-content-new-project-form.test.tsx

@@ -20,7 +20,7 @@ describe('<ModalContentNewProjectForm />', function () {
 
   afterEach(function () {
     this.locationStub.restore()
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('submits form', async function () {
@@ -49,7 +49,7 @@ describe('<ModalContentNewProjectForm />', function () {
 
     fireEvent.click(createButton)
 
-    expect(newProjectMock.called()).to.be.true
+    expect(newProjectMock.callHistory.called()).to.be.true
 
     await waitFor(() => {
       sinon.assert.calledOnce(assignStub)
@@ -76,7 +76,7 @@ describe('<ModalContentNewProjectForm />', function () {
     })
     fireEvent.click(createButton)
 
-    expect(newProjectMock.called()).to.be.true
+    expect(newProjectMock.callHistory.called()).to.be.true
 
     await waitFor(() => {
       screen.getByText(errorMessage)
@@ -102,7 +102,7 @@ describe('<ModalContentNewProjectForm />', function () {
     })
     fireEvent.click(createButton)
 
-    expect(newProjectMock.called()).to.be.true
+    expect(newProjectMock.callHistory.called()).to.be.true
 
     await waitFor(() => {
       screen.getByText(errorMessage)
@@ -141,7 +141,7 @@ describe('<ModalContentNewProjectForm />', function () {
     })
     fireEvent.click(createButton)
 
-    expect(newProjectMock.called()).to.be.true
+    expect(newProjectMock.callHistory.called()).to.be.true
 
     await waitFor(() => {
       screen.getByText(errorMessage)

+ 55 - 54
services/web/test/frontend/features/project-list/components/notifications.test.tsx

@@ -69,7 +69,7 @@ describe('<UserNotifications />', function () {
   }
 
   beforeEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
 
     // at least one project is required to show some notifications
     const projects = [{}] as Project[]
@@ -83,7 +83,7 @@ describe('<UserNotifications />', function () {
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   describe('<Common>', function () {
@@ -93,7 +93,7 @@ describe('<UserNotifications />', function () {
     })
 
     afterEach(function () {
-      fetchMock.reset()
+      fetchMock.removeRoutes().clearHistory()
     })
 
     it('accepts project invite', async function () {
@@ -106,14 +106,14 @@ describe('<UserNotifications />', function () {
       ])
 
       renderWithinProjectListProvider(Common)
-      await fetchMock.flush(true)
+      await fetchMock.callHistory.flush(true)
 
       const deleteMock = fetchMock.delete(
         `/notifications/${reconfiguredNotification._id}`,
         200
       )
       const acceptMock = fetchMock.post(
-        `project/${notificationProjectInvite.messageOpts.projectId}/invite/token/${notificationProjectInvite.messageOpts.token}/accept`,
+        `/project/${notificationProjectInvite.messageOpts.projectId}/invite/token/${notificationProjectInvite.messageOpts.token}/accept`,
         200
       )
 
@@ -134,8 +134,9 @@ describe('<UserNotifications />', function () {
         screen.getByRole('button', { name: /joining/i })
       )
 
-      expect(acceptMock.called()).to.be.true
-      screen.getByText(/joined/i)
+      expect(acceptMock.callHistory.called()).to.be.true
+      await screen.findByText(/joined/i)
+
       expect(screen.queryByRole('button', { name: /join project/i })).to.be.null
 
       const openProject = screen.getByRole('button', { name: /open project/i })
@@ -146,7 +147,7 @@ describe('<UserNotifications />', function () {
       const closeBtn = screen.getByRole('button', { name: /close/i })
       fireEvent.click(closeBtn)
 
-      expect(deleteMock.called()).to.be.true
+      expect(deleteMock.callHistory.called()).to.be.true
       expect(screen.queryByRole('alert')).to.be.null
     })
 
@@ -160,9 +161,9 @@ describe('<UserNotifications />', function () {
       ])
 
       renderWithinProjectListProvider(Common)
-      await fetchMock.flush(true)
+      await fetchMock.callHistory.flush(true)
       fetchMock.post(
-        `project/${notificationProjectInvite.messageOpts.projectId}/invite/token/${notificationProjectInvite.messageOpts.token}/accept`,
+        `/project/${notificationProjectInvite.messageOpts.projectId}/invite/token/${notificationProjectInvite.messageOpts.token}/accept`,
         500
       )
 
@@ -179,7 +180,7 @@ describe('<UserNotifications />', function () {
         screen.getByRole('button', { name: /joining/i })
       )
 
-      expect(fetchMock.called()).to.be.true
+      expect(fetchMock.callHistory.called()).to.be.true
       screen.getByRole('button', { name: /join project/i })
       expect(screen.queryByRole('button', { name: /open project/i })).to.be.null
     })
@@ -194,7 +195,7 @@ describe('<UserNotifications />', function () {
       ])
 
       renderWithinProjectListProvider(Common)
-      await fetchMock.flush(true)
+      await fetchMock.callHistory.flush(true)
       fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
 
       screen.getByRole('alert')
@@ -207,7 +208,7 @@ describe('<UserNotifications />', function () {
       const closeBtn = screen.getByRole('button', { name: /close/i })
       fireEvent.click(closeBtn)
 
-      expect(fetchMock.called()).to.be.true
+      expect(fetchMock.callHistory.called()).to.be.true
       expect(screen.queryByRole('alert')).to.be.null
     })
 
@@ -225,7 +226,7 @@ describe('<UserNotifications />', function () {
       ])
 
       renderWithinProjectListProvider(Common)
-      await fetchMock.flush(true)
+      await fetchMock.callHistory.flush(true)
       fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
 
       screen.getByRole('alert')
@@ -246,7 +247,7 @@ describe('<UserNotifications />', function () {
       const closeBtn = screen.getByRole('button', { name: /close/i })
       fireEvent.click(closeBtn)
 
-      expect(fetchMock.called()).to.be.true
+      expect(fetchMock.callHistory.called()).to.be.true
       expect(screen.queryByRole('alert')).to.be.null
     })
 
@@ -264,7 +265,7 @@ describe('<UserNotifications />', function () {
       ])
 
       renderWithinProjectListProvider(Common)
-      await fetchMock.flush(true)
+      await fetchMock.callHistory.flush(true)
       fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
 
       screen.getByRole('alert')
@@ -281,7 +282,7 @@ describe('<UserNotifications />', function () {
       const closeBtn = screen.getByRole('button', { name: /close/i })
       fireEvent.click(closeBtn)
 
-      expect(fetchMock.called()).to.be.true
+      expect(fetchMock.callHistory.called()).to.be.true
       expect(screen.queryByRole('alert')).to.be.null
     })
 
@@ -294,7 +295,7 @@ describe('<UserNotifications />', function () {
       ])
 
       renderWithinProjectListProvider(Common)
-      await fetchMock.flush(true)
+      await fetchMock.callHistory.flush(true)
 
       screen.getByRole('alert')
       screen.getByText(/file limit/i)
@@ -323,7 +324,7 @@ describe('<UserNotifications />', function () {
       ])
 
       renderWithinProjectListProvider(Common)
-      await fetchMock.flush(true)
+      await fetchMock.callHistory.flush(true)
       fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
 
       screen.getByRole('alert')
@@ -337,7 +338,7 @@ describe('<UserNotifications />', function () {
       const closeBtn = screen.getByRole('button', { name: /close/i })
       fireEvent.click(closeBtn)
 
-      expect(fetchMock.called()).to.be.true
+      expect(fetchMock.callHistory.called()).to.be.true
       expect(screen.queryByRole('alert')).to.be.null
     })
 
@@ -355,7 +356,7 @@ describe('<UserNotifications />', function () {
       ])
 
       renderWithinProjectListProvider(Common)
-      await fetchMock.flush(true)
+      await fetchMock.callHistory.flush(true)
       fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
 
       screen.getByRole('alert')
@@ -371,7 +372,7 @@ describe('<UserNotifications />', function () {
       const closeBtn = screen.getByRole('button', { name: /close/i })
       fireEvent.click(closeBtn)
 
-      expect(fetchMock.called()).to.be.true
+      expect(fetchMock.callHistory.called()).to.be.true
       expect(screen.queryByRole('alert')).to.be.null
     })
 
@@ -385,7 +386,7 @@ describe('<UserNotifications />', function () {
       ])
 
       renderWithinProjectListProvider(Common)
-      await fetchMock.flush(true)
+      await fetchMock.callHistory.flush(true)
       fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
 
       screen.getByRole('alert')
@@ -394,7 +395,7 @@ describe('<UserNotifications />', function () {
       const closeBtn = screen.getByRole('button', { name: /close/i })
       fireEvent.click(closeBtn)
 
-      expect(fetchMock.called()).to.be.true
+      expect(fetchMock.callHistory.called()).to.be.true
       expect(screen.queryByRole('alert')).to.be.null
     })
 
@@ -414,7 +415,7 @@ describe('<UserNotifications />', function () {
           ])
 
           renderWithinProjectListProvider(Common)
-          await fetchMock.flush(true)
+          await fetchMock.callHistory.flush(true)
           fetchMock.delete(`/notifications/${notificationGroupInvite._id}`, 200)
           screen.getByRole('alert')
           screen.getByText('inviter@overleaf.com')
@@ -444,7 +445,7 @@ describe('<UserNotifications />', function () {
             )
 
             renderWithinProjectListProvider(Common)
-            await fetchMock.flush(true)
+            await fetchMock.callHistory.flush(true)
             fetchMock.delete(
               `/notifications/${notificationGroupInvite._id}`,
               200
@@ -465,11 +466,11 @@ describe('<UserNotifications />', function () {
   describe('<Institution>', function () {
     beforeEach(function () {
       Object.assign(getMeta('ol-ExposedSettings'), exposedSettings)
-      fetchMock.reset()
+      fetchMock.removeRoutes().clearHistory()
     })
 
     afterEach(function () {
-      fetchMock.reset()
+      fetchMock.removeRoutes().clearHistory()
     })
 
     it('shows sso available', function () {
@@ -513,7 +514,7 @@ describe('<UserNotifications />', function () {
       const closeBtn = screen.getByRole('button', { name: /close/i })
       fireEvent.click(closeBtn)
 
-      expect(fetchMock.called()).to.be.true
+      expect(fetchMock.callHistory.called()).to.be.true
       expect(screen.queryByRole('alert')).to.be.null
     })
 
@@ -537,7 +538,7 @@ describe('<UserNotifications />', function () {
       const closeBtn = screen.getByRole('button', { name: /close/i })
       fireEvent.click(closeBtn)
 
-      expect(fetchMock.called()).to.be.true
+      expect(fetchMock.callHistory.called()).to.be.true
       expect(screen.queryByRole('alert')).to.be.null
     })
 
@@ -563,7 +564,7 @@ describe('<UserNotifications />', function () {
       const closeBtn = screen.getByRole('button', { name: /close/i })
       fireEvent.click(closeBtn)
 
-      expect(fetchMock.called()).to.be.true
+      expect(fetchMock.callHistory.called()).to.be.true
       expect(screen.queryByRole('alert')).to.be.null
     })
 
@@ -658,7 +659,7 @@ describe('<UserNotifications />', function () {
     })
 
     afterEach(function () {
-      fetchMock.reset()
+      fetchMock.removeRoutes().clearHistory()
       this.clock.restore()
     })
 
@@ -670,7 +671,7 @@ describe('<UserNotifications />', function () {
         window.metaAttributesCache.set('ol-userEmails', userEmails)
 
         renderWithinProjectListProvider(ConfirmEmail)
-        await fetchMock.flush(true)
+        await fetchMock.callHistory.flush(true)
         fetchMock.post('/user/emails/resend_confirmation', 200)
 
         const email = userEmails[0].email
@@ -693,7 +694,7 @@ describe('<UserNotifications />', function () {
           screen.getByRole('button', { name: /resend/i })
         )
 
-        expect(fetchMock.called()).to.be.true
+        expect(fetchMock.callHistory.called()).to.be.true
         expect(screen.queryByRole('alert')).to.be.null
       })
     }
@@ -712,7 +713,7 @@ describe('<UserNotifications />', function () {
       window.metaAttributesCache.set('ol-userEmails', [untrustedUserData])
 
       renderWithinProjectListProvider(ConfirmEmail)
-      await fetchMock.flush(true)
+      await fetchMock.callHistory.flush(true)
       fetchMock.post('/user/emails/resend_confirmation', 200)
 
       const email = untrustedUserData.email
@@ -730,7 +731,7 @@ describe('<UserNotifications />', function () {
         screen.getByRole('button', { name: /resend/i })
       )
 
-      expect(fetchMock.called()).to.be.true
+      expect(fetchMock.callHistory.called()).to.be.true
       expect(screen.queryByRole('alert')).to.be.null
     })
 
@@ -738,7 +739,7 @@ describe('<UserNotifications />', function () {
       window.metaAttributesCache.set('ol-userEmails', [unconfirmedUserData])
 
       renderWithinProjectListProvider(ConfirmEmail)
-      await fetchMock.flush(true)
+      await fetchMock.callHistory.flush(true)
       fetchMock.post('/user/emails/resend_confirmation', 500)
 
       const resendButtons = screen.getAllByRole('button', { name: /resend/i })
@@ -752,7 +753,7 @@ describe('<UserNotifications />', function () {
         )
       )
 
-      expect(fetchMock.called()).to.be.true
+      expect(fetchMock.callHistory.called()).to.be.true
       screen.getByText(/something went wrong/i)
     })
 
@@ -764,7 +765,7 @@ describe('<UserNotifications />', function () {
         window.metaAttributesCache.set('ol-usersBestSubscription', subscription)
 
         renderWithinProjectListProvider(ConfirmEmail)
-        await fetchMock.flush(true)
+        await fetchMock.callHistory.flush(true)
 
         const alert = screen.getByRole('alert')
         const email = unconfirmedCommonsUserData.email
@@ -785,7 +786,7 @@ describe('<UserNotifications />', function () {
         window.metaAttributesCache.set('ol-usersBestSubscription', subscription)
 
         renderWithinProjectListProvider(ConfirmEmail)
-        await fetchMock.flush(true)
+        await fetchMock.callHistory.flush(true)
 
         const alert = screen.getByRole('alert')
         const email = unconfirmedCommonsUserData.email
@@ -818,12 +819,12 @@ describe('<UserNotifications />', function () {
         reload: sinon.stub(),
         setHash: sinon.stub(),
       })
-      fetchMock.reset()
+      fetchMock.removeRoutes().clearHistory()
     })
 
     afterEach(function () {
       this.locationStub.restore()
-      fetchMock.reset()
+      fetchMock.removeRoutes().clearHistory()
     })
 
     it('shows reconfirm message with SSO disabled', async function () {
@@ -860,12 +861,12 @@ describe('<UserNotifications />', function () {
         .be.null
       expect(screen.queryByRole('link', { name: /remove it/i })).to.be.null
       expect(screen.queryByRole('link', { name: /learn more/i })).to.be.null
-      expect(sendReconfirmationMock.called()).to.be.true
+      expect(sendReconfirmationMock.callHistory.called()).to.be.true
       fireEvent.click(
         screen.getByRole('button', { name: /resend confirmation email/i })
       )
       await waitForElementToBeRemoved(() => screen.getByText('Sending…'))
-      expect(sendReconfirmationMock.calls()).to.have.lengthOf(2)
+      expect(sendReconfirmationMock.callHistory.calls()).to.have.lengthOf(2)
     })
 
     it('shows reconfirm message with SSO enabled', async function () {
@@ -905,7 +906,7 @@ describe('<UserNotifications />', function () {
   describe('<GroupsAndEnterpriseBanner />', function () {
     beforeEach(function () {
       localStorage.clear()
-      fetchMock.reset()
+      fetchMock.removeRoutes().clearHistory()
 
       // at least one project is required to show some notifications
       const projects = [{}] as Project[]
@@ -924,14 +925,14 @@ describe('<UserNotifications />', function () {
     })
 
     afterEach(function () {
-      fetchMock.reset()
+      fetchMock.removeRoutes().clearHistory()
     })
 
     it('does not show the banner for users that are in group or are affiliated', async function () {
       window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', false)
 
       renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
-      await fetchMock.flush(true)
+      await fetchMock.callHistory.flush(true)
 
       expect(screen.queryByRole('button', { name: 'Contact Sales' })).to.be.null
     })
@@ -941,7 +942,7 @@ describe('<UserNotifications />', function () {
       localStorage.setItem('has_dismissed_groups_and_enterprise_banner', true)
 
       renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
-      await fetchMock.flush(true)
+      await fetchMock.callHistory.flush(true)
 
       expect(screen.queryByRole('button', { name: 'Contact Sales' })).to.not.be
         .null
@@ -957,7 +958,7 @@ describe('<UserNotifications />', function () {
       )
 
       renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
-      await fetchMock.flush(true)
+      await fetchMock.callHistory.flush(true)
 
       expect(screen.queryByRole('button', { name: 'Contact Sales' })).to.not.be
         .null
@@ -973,7 +974,7 @@ describe('<UserNotifications />', function () {
       )
 
       renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
-      await fetchMock.flush(true)
+      await fetchMock.callHistory.flush(true)
 
       expect(screen.queryByRole('button', { name: 'Contact Sales' })).to.be.null
     })
@@ -981,7 +982,7 @@ describe('<UserNotifications />', function () {
     describe('users that are not in group and are not affiliated', function () {
       beforeEach(function () {
         localStorage.clear()
-        fetchMock.reset()
+        fetchMock.removeRoutes().clearHistory()
 
         // at least one project is required to show some notifications
         const projects = [{}] as Project[]
@@ -997,7 +998,7 @@ describe('<UserNotifications />', function () {
       })
 
       afterEach(function () {
-        fetchMock.reset()
+        fetchMock.removeRoutes().clearHistory()
       })
 
       after(function () {
@@ -1011,7 +1012,7 @@ describe('<UserNotifications />', function () {
         )
 
         renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
-        await fetchMock.flush(true)
+        await fetchMock.callHistory.flush(true)
 
         screen.getByText(
           'Overleaf On-Premises: Does your company want to keep its data within its firewall? Overleaf offers Server Pro, an on-premises solution for companies. Get in touch to learn more.'
@@ -1028,7 +1029,7 @@ describe('<UserNotifications />', function () {
         )
 
         renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
-        await fetchMock.flush(true)
+        await fetchMock.callHistory.flush(true)
 
         screen.getByText(
           'Why do Fortune 500 companies and top research institutions trust Overleaf to streamline their collaboration? Get in touch to learn more.'

+ 85 - 60
services/web/test/frontend/features/project-list/components/project-list-root.test.tsx

@@ -79,7 +79,7 @@ describe('<ProjectListRoot />', function () {
 
   afterEach(function () {
     sendMBSpy.restore()
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
     this.locationStub.restore()
   })
 
@@ -88,11 +88,11 @@ describe('<ProjectListRoot />', function () {
       renderWithProjectListContext(<ProjectListRoot />, {
         projects: [],
       })
-      await fetchMock.flush(true)
+      await fetchMock.callHistory.flush(true)
     })
 
     it('the welcome page is displayed', async function () {
-      screen.getByRole('heading', { name: 'Welcome to Overleaf' })
+      await screen.findByRole('heading', { name: 'Welcome to Overleaf' })
     })
 
     it('the email confirmation alert is not displayed', async function () {
@@ -110,7 +110,7 @@ describe('<ProjectListRoot />', function () {
         projects: fullList,
       })
       this.unmount = unmount
-      await fetchMock.flush(true)
+      await fetchMock.callHistory.flush(true)
       await screen.findByRole('table')
     })
 
@@ -172,13 +172,17 @@ describe('<ProjectListRoot />', function () {
           await waitFor(
             () =>
               expect(
-                archiveProjectMock.called(`/project/${project1Id}/archive`)
+                archiveProjectMock.callHistory.called(
+                  `/project/${project1Id}/archive`
+                )
               ).to.be.true
           )
           await waitFor(
             () =>
               expect(
-                archiveProjectMock.called(`/project/${project2Id}/archive`)
+                archiveProjectMock.callHistory.called(
+                  `/project/${project2Id}/archive`
+                )
               ).to.be.true
           )
         })
@@ -203,13 +207,19 @@ describe('<ProjectListRoot />', function () {
 
           await waitFor(
             () =>
-              expect(trashProjectMock.called(`/project/${project1Id}/trash`)).to
-                .be.true
+              expect(
+                trashProjectMock.callHistory.called(
+                  `/project/${project1Id}/trash`
+                )
+              ).to.be.true
           )
           await waitFor(
             () =>
-              expect(trashProjectMock.called(`/project/${project2Id}/trash`)).to
-                .be.true
+              expect(
+                trashProjectMock.callHistory.called(
+                  `/project/${project2Id}/trash`
+                )
+              ).to.be.true
           )
         })
 
@@ -281,8 +291,8 @@ describe('<ProjectListRoot />', function () {
           })
           fireEvent.click(unarchiveButton)
 
-          await fetchMock.flush(true)
-          expect(fetchMock.done()).to.be.true
+          await fetchMock.callHistory.flush(true)
+          expect(fetchMock.callHistory.done()).to.be.true
 
           await screen.findByText('No projects')
         })
@@ -296,8 +306,8 @@ describe('<ProjectListRoot />', function () {
             archivedProjects.length - 1
           )
 
-          await fetchMock.flush(true)
-          expect(fetchMock.done()).to.be.true
+          await fetchMock.callHistory.flush(true)
+          expect(fetchMock.callHistory.done()).to.be.true
 
           expect(screen.queryByText('No projects')).to.be.null
         })
@@ -348,8 +358,8 @@ describe('<ProjectListRoot />', function () {
             within(actionsToolbar).getByText<HTMLButtonElement>('Restore')
           fireEvent.click(untrashButton)
 
-          await fetchMock.flush(true)
-          expect(fetchMock.done()).to.be.true
+          await fetchMock.callHistory.flush(true)
+          expect(fetchMock.callHistory.done()).to.be.true
 
           await screen.findByText('No projects')
         })
@@ -361,8 +371,8 @@ describe('<ProjectListRoot />', function () {
           const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
           expect(allCheckboxesChecked.length).to.equal(trashedList.length - 1)
 
-          await fetchMock.flush(true)
-          expect(fetchMock.done()).to.be.true
+          await fetchMock.callHistory.flush(true)
+          expect(fetchMock.callHistory.done()).to.be.true
 
           expect(screen.queryByText('No projects')).to.be.null
         })
@@ -386,26 +396,28 @@ describe('<ProjectListRoot />', function () {
           fireEvent.click(confirmButton)
           expect(confirmButton.disabled).to.be.true
 
-          await fetchMock.flush(true)
-          expect(fetchMock.done()).to.be.true
+          await fetchMock.callHistory.flush(true)
+          expect(fetchMock.callHistory.done()).to.be.true
 
-          const calls = fetchMock.calls().map(([url]) => url)
+          const calls = fetchMock.callHistory.calls().map(({ url }) => url)
 
           trashedList.forEach(project => {
-            expect(calls).to.contain(`/project/${project.id}/archive`)
+            expect(calls).to.contain(
+              `https://www.test-overleaf.com/project/${project.id}/archive`
+            )
           })
         })
 
         it('removes only selected projects from view when leaving', async function () {
           // rerender content with different projects
           this.unmount()
-          fetchMock.restore()
+          fetchMock.removeRoutes().clearHistory()
 
           renderWithProjectListContext(<ProjectListRoot />, {
             projects: leavableList,
           })
 
-          await fetchMock.flush(true)
+          await fetchMock.callHistory.flush(true)
           await screen.findByRole('table')
 
           expect(leavableList.length).to.be.greaterThan(0)
@@ -446,25 +458,27 @@ describe('<ProjectListRoot />', function () {
           fireEvent.click(confirmButton)
           expect(confirmButton.disabled).to.be.true
 
-          await fetchMock.flush(true)
-          expect(fetchMock.done()).to.be.true
+          await fetchMock.callHistory.flush(true)
+          expect(fetchMock.callHistory.done()).to.be.true
 
-          const calls = fetchMock.calls().map(([url]) => url)
+          const calls = fetchMock.callHistory.calls().map(({ url }) => url)
           leavableList.forEach(project => {
-            expect(calls).to.contain(`/project/${project.id}/leave`)
+            expect(calls).to.contain(
+              `https://www.test-overleaf.com/project/${project.id}/leave`
+            )
           })
         })
 
         it('removes only selected projects from view when deleting', async function () {
           // rerender content with different projects
           this.unmount()
-          fetchMock.restore()
+          fetchMock.removeRoutes().clearHistory()
 
           renderWithProjectListContext(<ProjectListRoot />, {
             projects: deletableList,
           })
 
-          await fetchMock.flush(true)
+          await fetchMock.callHistory.flush(true)
           await screen.findByRole('table')
 
           expect(deletableList.length).to.be.greaterThan(0)
@@ -505,19 +519,21 @@ describe('<ProjectListRoot />', function () {
           fireEvent.click(confirmButton)
           expect(confirmButton.disabled).to.be.true
 
-          await fetchMock.flush(true)
-          expect(fetchMock.done()).to.be.true
+          await fetchMock.callHistory.flush(true)
+          expect(fetchMock.callHistory.done()).to.be.true
 
-          const calls = fetchMock.calls().map(([url]) => url)
+          const calls = fetchMock.callHistory.calls().map(({ url }) => url)
           deletableList.forEach(project => {
-            expect(calls).to.contain(`/project/${project.id}`)
+            expect(calls).to.contain(
+              `https://www.test-overleaf.com/project/${project.id}`
+            )
           })
         })
 
         it('removes only selected projects from view when deleting and leaving', async function () {
           // rerender content with different projects
           this.unmount()
-          fetchMock.restore()
+          fetchMock.removeRoutes().clearHistory()
 
           const deletableAndLeavableList = [...deletableList, ...leavableList]
 
@@ -525,7 +541,7 @@ describe('<ProjectListRoot />', function () {
             projects: deletableAndLeavableList,
           })
 
-          await fetchMock.flush(true)
+          await fetchMock.callHistory.flush(true)
           await screen.findByRole('table')
 
           expect(deletableList.length).to.be.greaterThan(0)
@@ -573,14 +589,14 @@ describe('<ProjectListRoot />', function () {
           fireEvent.click(confirmButton)
           expect(confirmButton.disabled).to.be.true
 
-          await fetchMock.flush(true)
-          expect(fetchMock.done()).to.be.true
+          await fetchMock.callHistory.flush(true)
+          expect(fetchMock.callHistory.done()).to.be.true
 
-          const calls = fetchMock.calls().map(([url]) => url)
+          const calls = fetchMock.callHistory.calls().map(({ url }) => url)
           deletableAndLeavableList.forEach(project => {
             expect(calls).to.contain.oneOf([
-              `/project/${project.id}`,
-              `/project/${project.id}/leave`,
+              `https://www.test-overleaf.com/project/${project.id}`,
+              `https://www.test-overleaf.com/project/${project.id}/leave`,
             ])
           })
         })
@@ -589,7 +605,7 @@ describe('<ProjectListRoot />', function () {
       describe('tags', function () {
         it('does not show archived or trashed project', async function () {
           this.unmount()
-          fetchMock.restore()
+          fetchMock.removeRoutes().clearHistory()
           window.metaAttributesCache.set('ol-tags', [
             {
               _id: this.tagId,
@@ -642,7 +658,7 @@ describe('<ProjectListRoot />', function () {
             )
             await waitFor(() => {
               expect(
-                trashProjectMock.called(
+                trashProjectMock.callHistory.called(
                   `/project/${projectsData[index].id}/trash`
                 )
               ).to.be.true
@@ -702,11 +718,13 @@ describe('<ProjectListRoot />', function () {
           })
           fireEvent.click(createButton)
 
-          await fetchMock.flush(true)
+          await fetchMock.callHistory.flush(true)
 
-          expect(fetchMock.called('/tag', { name: this.newTagName })).to.be.true
           expect(
-            fetchMock.called(`/tag/${this.newTagId}/projects`, {
+            fetchMock.callHistory.called('/tag', { name: this.newTagName })
+          ).to.be.true
+          expect(
+            fetchMock.callHistory.called(`/tag/${this.newTagId}/projects`, {
               body: {
                 projectIds: [projectsData[0].id, projectsData[1].id],
               },
@@ -734,10 +752,10 @@ describe('<ProjectListRoot />', function () {
           )
           fireEvent.click(tagButton)
 
-          await fetchMock.flush(true)
+          await fetchMock.callHistory.flush(true)
 
           expect(
-            deleteProjectsFromTagMock.called(
+            deleteProjectsFromTagMock.callHistory.called(
               `/tag/${this.tagId}/projects/remove`,
               {
                 body: {
@@ -770,14 +788,17 @@ describe('<ProjectListRoot />', function () {
           )
           fireEvent.click(tagButton)
 
-          await fetchMock.flush(true)
+          await fetchMock.callHistory.flush(true)
 
           expect(
-            addProjectsToTagMock.called(`/tag/${this.tagId}/projects`, {
-              body: {
-                projectIds: [projectsData[2].id],
-              },
-            })
+            addProjectsToTagMock.callHistory.called(
+              `/tag/${this.tagId}/projects`,
+              {
+                body: {
+                  projectIds: [projectsData[2].id],
+                },
+              }
+            )
           ).to.be.true
           screen.getByRole('button', { name: `${this.tagName} (3)` })
         })
@@ -919,10 +940,12 @@ describe('<ProjectListRoot />', function () {
             expect(confirmButton.disabled).to.be.false
             fireEvent.click(confirmButton)
 
-            await fetchMock.flush(true)
+            await fetchMock.callHistory.flush(true)
 
             expect(
-              renameProjectMock.called(`/project/${projectsData[0].id}/rename`)
+              renameProjectMock.callHistory.called(
+                `/project/${projectsData[0].id}/rename`
+              )
             ).to.be.true
 
             const table = await screen.findByRole('table')
@@ -982,10 +1005,12 @@ describe('<ProjectListRoot />', function () {
             ) as HTMLElement
             fireEvent.click(copyConfirmButton)
 
-            await fetchMock.flush(true)
+            await fetchMock.callHistory.flush(true)
 
             expect(
-              cloneProjectMock.called(`/project/${projectsData[1].id}/clone`)
+              cloneProjectMock.callHistory.called(
+                `/project/${projectsData[1].id}/clone`
+              )
             ).to.be.true
 
             expect(sendMBSpy).to.have.been.calledTwice
@@ -1149,8 +1174,8 @@ describe('<ProjectListRoot />', function () {
         ) as HTMLElement
         fireEvent.click(copyConfirmButton)
 
-        await fetchMock.flush(true)
-        expect(fetchMock.done()).to.be.true
+        await fetchMock.callHistory.flush(true)
+        expect(fetchMock.callHistory.done()).to.be.true
 
         expect(sendMBSpy).to.have.been.calledTwice
         expect(sendMBSpy).to.have.been.calledWith('loads_v2_dash')

+ 2 - 2
services/web/test/frontend/features/project-list/components/project-search.test.tsx

@@ -12,11 +12,11 @@ describe('Project list search form', function () {
 
   beforeEach(function () {
     sendMBSpy = sinon.spy(eventTracking, 'sendMB')
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
     sendMBSpy.restore()
   })
 

+ 10 - 10
services/web/test/frontend/features/project-list/components/sidebar/add-affiliation.test.tsx

@@ -14,11 +14,11 @@ describe('Add affiliation widget', function () {
   }
 
   beforeEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('renders the component', async function () {
@@ -27,8 +27,8 @@ describe('Add affiliation widget', function () {
 
     renderWithProjectListContext(<AddAffiliation />)
 
-    await fetchMock.flush(true)
-    await waitFor(() => expect(fetchMock.called('/api/project')))
+    await fetchMock.callHistory.flush(true)
+    await waitFor(() => expect(fetchMock.callHistory.called('/api/project')))
 
     screen.getByText(/are you affiliated with an institution/i)
     const addAffiliationLink = screen.getByRole('button', {
@@ -43,8 +43,8 @@ describe('Add affiliation widget', function () {
 
     renderWithProjectListContext(<AddAffiliation />)
 
-    await fetchMock.flush(true)
-    await waitFor(() => expect(fetchMock.called('/api/project')))
+    await fetchMock.callHistory.flush(true)
+    await waitFor(() => expect(fetchMock.callHistory.called('/api/project')))
 
     validateNonExistence()
   })
@@ -57,8 +57,8 @@ describe('Add affiliation widget', function () {
       projects: [],
     })
 
-    await fetchMock.flush(true)
-    await waitFor(() => expect(fetchMock.called('/api/project')))
+    await fetchMock.callHistory.flush(true)
+    await waitFor(() => expect(fetchMock.callHistory.called('/api/project')))
 
     validateNonExistence()
   })
@@ -69,8 +69,8 @@ describe('Add affiliation widget', function () {
 
     renderWithProjectListContext(<AddAffiliation />)
 
-    await fetchMock.flush(true)
-    await waitFor(() => expect(fetchMock.called('/api/project')))
+    await fetchMock.callHistory.flush(true)
+    await waitFor(() => expect(fetchMock.callHistory.called('/api/project')))
 
     validateNonExistence()
   })

+ 17 - 9
services/web/test/frontend/features/project-list/components/sidebar/tags-list.test.tsx

@@ -29,16 +29,16 @@ describe('<TagsList />', function () {
     })
     fetchMock.post('express:/tag/:tagId/projects', 200)
     fetchMock.post('express:/tag/:tagId/edit', 200)
-    fetchMock.delete('express:/tag/:tagId', 200)
+    fetchMock.delete('express:/tag/:tagId', 200, { name: 'delete tag' })
 
     renderWithProjectListContext(<TagsList />)
 
-    await fetchMock.flush(true)
-    await waitFor(() => expect(fetchMock.called('/api/project')))
+    await fetchMock.callHistory.flush(true)
+    await waitFor(() => expect(fetchMock.callHistory.called('/api/project')))
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('displays the tags list', function () {
@@ -146,7 +146,9 @@ describe('<TagsList />', function () {
 
       await fireEvent.click(createButton)
 
-      await waitFor(() => expect(fetchMock.called(`/tag`)).to.be.true)
+      await waitFor(
+        () => expect(fetchMock.callHistory.called(`/tag`)).to.be.true
+      )
 
       expect(screen.queryByRole('dialog', { hidden: false })).to.be.null
 
@@ -237,7 +239,9 @@ describe('<TagsList />', function () {
 
       await fireEvent.click(saveButton)
 
-      await waitFor(() => expect(fetchMock.called(`/tag/abc123def456/rename`)))
+      await waitFor(() =>
+        expect(fetchMock.callHistory.called(`/tag/abc123def456/rename`))
+      )
 
       expect(screen.queryByRole('dialog', { hidden: false })).to.be.null
 
@@ -278,7 +282,9 @@ describe('<TagsList />', function () {
       const deleteButton = within(modal).getByRole('button', { name: 'Delete' })
       await fireEvent.click(deleteButton)
 
-      await waitFor(() => expect(fetchMock.called(`/tag/bcd234efg567`)))
+      await waitFor(() =>
+        expect(fetchMock.callHistory.called(`/tag/bcd234efg567`))
+      )
 
       expect(screen.queryByRole('dialog', { hidden: false })).to.be.null
       expect(
@@ -289,13 +295,15 @@ describe('<TagsList />', function () {
     })
 
     it('a failed request displays an error message', async function () {
-      fetchMock.delete('express:/tag/:tagId', 500, { overwriteRoutes: true })
+      fetchMock.modifyRoute('delete tag', { response: { status: 500 } })
 
       const modal = screen.getAllByRole('dialog', { hidden: false })[0]
       const deleteButton = within(modal).getByRole('button', { name: 'Delete' })
       await fireEvent.click(deleteButton)
 
-      await waitFor(() => expect(fetchMock.called(`/tag/bcd234efg567`)))
+      await waitFor(() =>
+        expect(fetchMock.callHistory.called(`/tag/bcd234efg567`))
+      )
 
       within(modal).getByText('Sorry, something went wrong')
     })

+ 5 - 5
services/web/test/frontend/features/project-list/components/system-messages.test.tsx

@@ -5,12 +5,12 @@ import SystemMessages from '@/shared/components/system-messages'
 
 describe('<SystemMessages />', function () {
   beforeEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
     localStorage.clear()
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
     localStorage.clear()
   })
 
@@ -22,7 +22,7 @@ describe('<SystemMessages />', function () {
     fetchMock.get(/\/system\/messages/, [data])
     render(<SystemMessages />)
 
-    await fetchMock.flush(true)
+    await fetchMock.callHistory.flush(true)
 
     screen.getByText(data.content)
     expect(screen.queryByRole('button', { name: /close/i })).to.be.null
@@ -36,7 +36,7 @@ describe('<SystemMessages />', function () {
     fetchMock.get(/\/system\/messages/, [data])
     render(<SystemMessages />)
 
-    await fetchMock.flush(true)
+    await fetchMock.callHistory.flush(true)
 
     screen.getByText(data.content)
     const closeBtn = screen.getByRole('button', { name: /close/i })
@@ -60,7 +60,7 @@ describe('<SystemMessages />', function () {
     window.metaAttributesCache.set('ol-currentUrl', currentUrl)
     render(<SystemMessages />)
 
-    await fetchMock.flush(true)
+    await fetchMock.callHistory.flush(true)
 
     const link = screen.getByRole('link', { name: /click here/i })
     expect(link.getAttribute('href')).to.equal(`${data.url}${currentUrl}`)

+ 5 - 2
services/web/test/frontend/features/project-list/components/table/cells/action-buttons/archive-project-button.test.tsx

@@ -67,8 +67,11 @@ describe('<ArchiveProjectButton />', function () {
 
     await waitFor(
       () =>
-        expect(archiveProjectMock.called(`/project/${project.id}/archive`)).to
-          .be.true
+        expect(
+          archiveProjectMock.callHistory.called(
+            `/project/${project.id}/archive`
+          )
+        ).to.be.true
     )
   })
 })

+ 1 - 1
services/web/test/frontend/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.test.tsx

@@ -28,7 +28,7 @@ describe('<CompileAndDownloadProjectPDFButton />', function () {
 
   afterEach(function () {
     locationStub.restore()
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
     sendMBSpy.restore()
   })
 

+ 5 - 2
services/web/test/frontend/features/project-list/components/table/cells/action-buttons/copy-project-button.test.tsx

@@ -66,8 +66,11 @@ describe('<CopyProjectButton />', function () {
 
     await waitFor(
       () =>
-        expect(copyProjectMock.called(`/project/${copyableProject.id}/clone`))
-          .to.be.true
+        expect(
+          copyProjectMock.callHistory.called(
+            `/project/${copyableProject.id}/clone`
+          )
+        ).to.be.true
     )
   })
 })

+ 2 - 1
services/web/test/frontend/features/project-list/components/table/cells/action-buttons/delete-project-button.test.tsx

@@ -69,7 +69,8 @@ describe('<DeleteProjectButton />', function () {
 
     await waitFor(
       () =>
-        expect(deleteProjectMock.called(`/project/${project.id}`)).to.be.true
+        expect(deleteProjectMock.callHistory.called(`/project/${project.id}`))
+          .to.be.true
     )
   })
 })

+ 3 - 2
services/web/test/frontend/features/project-list/components/table/cells/action-buttons/leave-project-button.test.tsx

@@ -75,8 +75,9 @@ describe('<LeaveProjectButtton />', function () {
 
     await waitFor(
       () =>
-        expect(leaveProjectMock.called(`/project/${project.id}/leave`)).to.be
-          .true
+        expect(
+          leaveProjectMock.callHistory.called(`/project/${project.id}/leave`)
+        ).to.be.true
     )
   })
 })

+ 3 - 2
services/web/test/frontend/features/project-list/components/table/cells/action-buttons/rename-project-button.test.tsx

@@ -63,8 +63,9 @@ describe('<RenameProjectButton />', function () {
 
     await waitFor(
       () =>
-        expect(renameProjectMock.called(`/project/${project.id}/rename`)).to.be
-          .true
+        expect(
+          renameProjectMock.callHistory.called(`/project/${project.id}/rename`)
+        ).to.be.true
     )
   })
 })

+ 3 - 2
services/web/test/frontend/features/project-list/components/table/cells/action-buttons/trash-project-button.test.tsx

@@ -57,8 +57,9 @@ describe('<TrashProjectButton />', function () {
 
     await waitFor(
       () =>
-        expect(trashProjectMock.called(`/project/${project.id}/trash`)).to.be
-          .true
+        expect(
+          trashProjectMock.callHistory.called(`/project/${project.id}/trash`)
+        ).to.be.true
     )
   })
 })

+ 5 - 2
services/web/test/frontend/features/project-list/components/table/cells/action-buttons/unarchive-project-button.test.tsx

@@ -57,8 +57,11 @@ describe('<UnarchiveProjectButton />', function () {
 
     await waitFor(
       () =>
-        expect(unarchiveProjectMock.called(`/project/${project.id}/archive`)).to
-          .be.true
+        expect(
+          unarchiveProjectMock.callHistory.called(
+            `/project/${project.id}/archive`
+          )
+        ).to.be.true
     )
   })
 })

+ 3 - 2
services/web/test/frontend/features/project-list/components/table/cells/action-buttons/untrash-project-button.test.tsx

@@ -49,8 +49,9 @@ describe('<UntrashProjectButton />', function () {
 
     await waitFor(
       () =>
-        expect(untrashProjectMock.called(`/project/${project.id}/trash`)).to.be
-          .true
+        expect(
+          untrashProjectMock.callHistory.called(`/project/${project.id}/trash`)
+        ).to.be.true
     )
   })
 })

+ 6 - 3
services/web/test/frontend/features/project-list/components/table/cells/inline-tags.test.tsx

@@ -60,9 +60,12 @@ describe('<InlineTags />', function () {
     await fireEvent.click(removeButton)
     await waitFor(() =>
       expect(
-        fetchMock.called(`/tag/789fff789fff/project/${copyableProject.id}`, {
-          method: 'DELETE',
-        })
+        fetchMock.callHistory.called(
+          `/tag/789fff789fff/project/${copyableProject.id}`,
+          {
+            method: 'DELETE',
+          }
+        )
       )
     )
     expect(screen.queryByText('My Test Tag')).to.not.exist

+ 7 - 7
services/web/test/frontend/features/project-list/components/table/project-list-table.test.tsx

@@ -11,11 +11,11 @@ describe('<ProjectListTable />', function () {
   beforeEach(function () {
     window.metaAttributesCache.set('ol-tags', [])
     window.metaAttributesCache.set('ol-user_id', userId)
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('renders the table', function () {
@@ -65,7 +65,7 @@ describe('<ProjectListTable />', function () {
     this.timeout(10000)
 
     renderWithProjectListContext(<ProjectListTable />)
-    await fetchMock.flush(true)
+    await fetchMock.callHistory.flush(true)
 
     const rows = screen.getAllByRole('row')
     rows.shift() // remove first row since it's the header
@@ -138,7 +138,7 @@ describe('<ProjectListTable />', function () {
 
   it('selects all projects when header checkbox checked', async function () {
     renderWithProjectListContext(<ProjectListTable />)
-    await fetchMock.flush(true)
+    await fetchMock.callHistory.flush(true)
     const checkbox = screen.getByLabelText('Select all projects')
     fireEvent.click(checkbox)
     const allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
@@ -149,7 +149,7 @@ describe('<ProjectListTable />', function () {
 
   it('unselects all projects when select all checkbox uchecked', async function () {
     renderWithProjectListContext(<ProjectListTable />)
-    await fetchMock.flush(true)
+    await fetchMock.callHistory.flush(true)
     const checkbox = screen.getByLabelText('Select all projects')
     fireEvent.click(checkbox)
     fireEvent.click(checkbox)
@@ -160,7 +160,7 @@ describe('<ProjectListTable />', function () {
 
   it('unselects select all projects checkbox when one project is unchecked', async function () {
     renderWithProjectListContext(<ProjectListTable />)
-    await fetchMock.flush(true)
+    await fetchMock.callHistory.flush(true)
     const checkbox = screen.getByLabelText('Select all projects')
     fireEvent.click(checkbox)
     let allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
@@ -173,7 +173,7 @@ describe('<ProjectListTable />', function () {
 
   it('only checks the checked project', async function () {
     renderWithProjectListContext(<ProjectListTable />)
-    await fetchMock.flush(true)
+    await fetchMock.callHistory.flush(true)
     const checkbox = screen.getByLabelText(`Select ${currentProjects[0].name}`)
     fireEvent.click(checkbox)
     const allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')

+ 1 - 1
services/web/test/frontend/features/project-list/components/table/project-tools/project-tools-rename.test.tsx

@@ -65,7 +65,7 @@ describe('<ProjectTools />', function () {
 
   afterEach(function () {
     window.metaAttributesCache.clear()
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('does not show the Rename option for a project owned by a different user', function () {

+ 4 - 2
services/web/test/frontend/features/project-list/components/table/project-tools/rename-project-modal.test.tsx

@@ -58,7 +58,9 @@ describe('<RenameProjectModal />', function () {
     await waitFor(
       () =>
         expect(
-          renameProjectMock.called(`/project/${currentProjects[0].id}/rename`)
+          renameProjectMock.callHistory.called(
+            `/project/${currentProjects[0].id}/rename`
+          )
         ).to.be.true
     )
   })
@@ -88,7 +90,7 @@ describe('<RenameProjectModal />', function () {
     const submitButton = within(modal).getByText('Rename') as HTMLButtonElement
     fireEvent.click(submitButton)
 
-    await waitFor(() => expect(postRenameMock.called()).to.be.true)
+    await waitFor(() => expect(postRenameMock.callHistory.called()).to.be.true)
 
     screen.getByText('Something went wrong. Please try again.')
   })

+ 1 - 1
services/web/test/frontend/features/project-list/helpers/render-with-context.tsx

@@ -58,5 +58,5 @@ export function renderWithProjectListContext(
 }
 
 export function resetProjectListContextFetch() {
-  fetchMock.reset()
+  fetchMock.removeRoutes().clearHistory()
 }

+ 27 - 27
services/web/test/frontend/features/settings/components/account-info-section.test.tsx

@@ -29,7 +29,7 @@ describe('<AccountInfoSection />', function () {
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('submits all inputs', async function () {
@@ -50,14 +50,14 @@ describe('<AccountInfoSection />', function () {
         name: 'Update',
       })
     )
-    expect(updateMock.called()).to.be.true
-    expect(JSON.parse(updateMock.lastCall()![1]!.body as string)).to.deep.equal(
-      {
-        email: 'john@watson.co.uk',
-        first_name: 'John',
-        last_name: 'Watson',
-      }
-    )
+    expect(updateMock.callHistory.called()).to.be.true
+    expect(
+      JSON.parse(updateMock.callHistory.calls().at(-1)?.options.body as string)
+    ).to.deep.equal({
+      email: 'john@watson.co.uk',
+      first_name: 'John',
+      last_name: 'Watson',
+    })
   })
 
   it('disables button on invalid email', async function () {
@@ -74,7 +74,7 @@ describe('<AccountInfoSection />', function () {
     expect(button.disabled).to.be.true
     fireEvent.click(button)
 
-    expect(updateMock.called()).to.be.false
+    expect(updateMock.callHistory.called()).to.be.false
   })
 
   it('shows inflight state and success message', async function () {
@@ -156,12 +156,12 @@ describe('<AccountInfoSection />', function () {
         name: 'Update',
       })
     )
-    expect(JSON.parse(updateMock.lastCall()![1]!.body as string)).to.deep.equal(
-      {
-        first_name: 'Sherlock',
-        last_name: 'Holmes',
-      }
-    )
+    expect(
+      JSON.parse(updateMock.callHistory.calls().at(-1)?.options.body as string)
+    ).to.deep.equal({
+      first_name: 'Sherlock',
+      last_name: 'Holmes',
+    })
   })
 
   it('disables email input', async function () {
@@ -187,12 +187,12 @@ describe('<AccountInfoSection />', function () {
         name: 'Update',
       })
     )
-    expect(JSON.parse(updateMock.lastCall()![1]!.body as string)).to.deep.equal(
-      {
-        first_name: 'Sherlock',
-        last_name: 'Holmes',
-      }
-    )
+    expect(
+      JSON.parse(updateMock.callHistory.calls().at(-1)?.options.body as string)
+    ).to.deep.equal({
+      first_name: 'Sherlock',
+      last_name: 'Holmes',
+    })
   })
 
   it('disables names input', async function () {
@@ -215,10 +215,10 @@ describe('<AccountInfoSection />', function () {
         name: 'Update',
       })
     )
-    expect(JSON.parse(updateMock.lastCall()![1]!.body as string)).to.deep.equal(
-      {
-        email: 'sherlock@holmes.co.uk',
-      }
-    )
+    expect(
+      JSON.parse(updateMock.callHistory.calls().at(-1)?.options.body as string)
+    ).to.deep.equal({
+      email: 'sherlock@holmes.co.uk',
+    })
   })
 })

+ 20 - 13
services/web/test/frontend/features/settings/components/emails/add-email-input.test.tsx

@@ -19,7 +19,7 @@ describe('<AddEmailInput/>', function () {
 
   beforeEach(function () {
     clearDomainCache()
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   describe('on initial render', function () {
@@ -69,7 +69,7 @@ describe('<AddEmailInput/>', function () {
     })
 
     it('should not make any request for institution domains', function () {
-      expect(fetchMock.called()).to.be.false
+      expect(fetchMock.callHistory.called()).to.be.false
     })
 
     it('should submit on Enter if email looks valid', async function () {
@@ -118,11 +118,18 @@ describe('<AddEmailInput/>', function () {
     })
 
     describe('when there is a domain match', function () {
-      beforeEach(function () {
+      beforeEach(async function () {
         fetchMock.get('express:/institutions/domains', testInstitutionData)
         fireEvent.change(screen.getByTestId('affiliations-email'), {
           target: { value: 'user@d' },
         })
+        // Wait for component to process the change and update the shadow input
+        await waitFor(() => {
+          const shadowInput = screen.getByTestId(
+            'affiliations-email-shadow'
+          ) as HTMLInputElement
+          expect(shadowInput.value).to.equal('user@domain.edu')
+        })
       })
 
       it('should render the text being typed along with the suggestion', async function () {
@@ -146,7 +153,7 @@ describe('<AddEmailInput/>', function () {
         fireEvent.change(screen.getByTestId('affiliations-email'), {
           target: { value: 'user@domain.edu' },
         })
-        await fetchMock.flush(true)
+        await fetchMock.callHistory.flush(true)
         expect(
           onChangeStub.calledWith(
             'user@domain.edu',
@@ -231,7 +238,7 @@ describe('<AddEmailInput/>', function () {
       })
 
       it('should cache the result and skip subsequent requests', async function () {
-        fetchMock.reset()
+        fetchMock.removeRoutes().clearHistory()
 
         // clear input
         fireEvent.change(screen.getByTestId('affiliations-email'), {
@@ -242,7 +249,7 @@ describe('<AddEmailInput/>', function () {
           target: { value: 'user@d' },
         })
 
-        expect(fetchMock.called()).to.be.false
+        expect(fetchMock.callHistory.called()).to.be.false
         expect(onChangeStub.calledWith('user@d')).to.equal(true)
         await waitFor(() => {
           const shadowInput = screen.getByTestId(
@@ -258,7 +265,7 @@ describe('<AddEmailInput/>', function () {
 
       afterEach(function () {
         clearDomainCache()
-        fetchMock.reset()
+        fetchMock.removeRoutes().clearHistory()
       })
 
       it('should not render the suggestion with blocked domain', async function () {
@@ -269,7 +276,7 @@ describe('<AddEmailInput/>', function () {
         fireEvent.change(screen.getByTestId('affiliations-email'), {
           target: { value: `user@${blockedDomain.split('.')[0]}` },
         })
-        await fetchMock.flush(true)
+        await fetchMock.callHistory.flush(true)
         expect(screen.queryByText(`user@${blockedDomain}`)).to.be.null
       })
 
@@ -286,7 +293,7 @@ describe('<AddEmailInput/>', function () {
             value: `user@subdomain.${blockedDomain.split('.')[0]}`,
           },
         })
-        await fetchMock.flush(true)
+        await fetchMock.callHistory.flush(true)
         expect(screen.queryByText(`user@subdomain.${blockedDomain}`)).to.be.null
       })
     })
@@ -307,7 +314,7 @@ describe('<AddEmailInput/>', function () {
 
         // make sure the next suggestions are delayed
         clearDomainCache()
-        fetchMock.reset()
+        fetchMock.removeRoutes().clearHistory()
         fetchMock.get('express:/institutions/domains', 200, { delay: 1000 })
       })
 
@@ -354,7 +361,7 @@ describe('<AddEmailInput/>', function () {
       })
 
       // subsequent requests fail
-      fetchMock.reset()
+      fetchMock.removeRoutes().clearHistory()
       fetchMock.get('express:/institutions/domains', 500)
     })
 
@@ -373,7 +380,7 @@ describe('<AddEmailInput/>', function () {
         expect(shadowInput.value).to.equal('')
       })
 
-      expect(fetchMock.called()).to.be.true // ensures `domainCache` hasn't been hit
+      expect(fetchMock.callHistory.called()).to.be.true // ensures `domainCache` hasn't been hit
     })
   })
 
@@ -384,7 +391,7 @@ describe('<AddEmailInput/>', function () {
       fireEvent.change(screen.getByTestId('affiliations-email'), {
         target: { value: 'user@other' },
       })
-      await fetchMock.flush(true)
+      await fetchMock.callHistory.flush(true)
       const shadowInput = screen.getByTestId(
         'affiliations-email-shadow'
       ) as HTMLInputElement

+ 5 - 5
services/web/test/frontend/features/settings/components/emails/emails-row.test.tsx

@@ -37,7 +37,7 @@ describe('<EmailsRow/>', function () {
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   describe('with unaffiliated email data', function () {
@@ -86,9 +86,9 @@ describe('<EmailsRow/>', function () {
 
     describe('when the email is not yet linked to the institution', function () {
       beforeEach(async function () {
-        fetchMock.reset()
+        fetchMock.removeRoutes().clearHistory()
         fetchMock.get(/\/user\/emails/, [affiliatedEmail, unconfirmedUserData])
-        await fetchMock.flush(true)
+        await fetchMock.callHistory.flush(true)
       })
 
       it('prompts the user to link to their institutional account', function () {
@@ -103,9 +103,9 @@ describe('<EmailsRow/>', function () {
     describe('when the email is already linked to the institution', function () {
       beforeEach(async function () {
         affiliatedEmail.samlProviderId = '1'
-        fetchMock.reset()
+        fetchMock.removeRoutes().clearHistory()
         fetchMock.get(/\/user\/emails/, [affiliatedEmail, unconfirmedUserData])
-        await fetchMock.flush(true)
+        await fetchMock.callHistory.flush(true)
       })
 
       it('prompts the user to login using their institutional account', function () {

+ 4 - 4
services/web/test/frontend/features/settings/components/emails/emails-section-actions.test.tsx

@@ -37,11 +37,11 @@ describe('email actions - make primary', function () {
     Object.assign(getMeta('ol-ExposedSettings'), {
       hasAffiliationsFeature: true,
     })
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   describe('disabled `make primary` button', function () {
@@ -236,11 +236,11 @@ describe('email actions - delete', function () {
     Object.assign(getMeta('ol-ExposedSettings'), {
       hasAffiliationsFeature: true,
     })
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('shows loader when deleting and removes the row', async function () {

+ 31 - 27
services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx

@@ -56,7 +56,7 @@ const institutionDomainData = [
 ] as const
 
 function resetFetchMock() {
-  fetchMock.reset()
+  fetchMock.removeRoutes().clearHistory()
   fetchMock.get('express:/institutions/domains', [])
 }
 
@@ -80,7 +80,7 @@ describe('<EmailsSection />', function () {
       hasSamlFeature: true,
       samlInitPath: 'saml/init',
     })
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   afterEach(function () {
@@ -97,7 +97,7 @@ describe('<EmailsSection />', function () {
   it('renders input', async function () {
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     render(<EmailsSection />)
-    await fetchMock.flush(true)
+    await fetchMock.callHistory.flush(true)
 
     const button = await screen.findByRole<HTMLButtonElement>('button', {
       name: /add another email/i,
@@ -112,7 +112,7 @@ describe('<EmailsSection />', function () {
     fetchMock.get(`/institutions/domains?hostname=email.com&limit=1`, 200)
     fetchMock.get(`/institutions/domains?hostname=email&limit=1`, 200)
     render(<EmailsSection />)
-    await fetchMock.flush(true)
+    await fetchMock.callHistory.flush(true)
 
     const button = await screen.findByRole<HTMLButtonElement>('button', {
       name: /add another email/i,
@@ -190,7 +190,7 @@ describe('<EmailsSection />', function () {
       { name: /add another email/i }
     )
 
-    await fetchMock.flush(true)
+    await fetchMock.callHistory.flush(true)
     resetFetchMock()
     fetchMock
       .get('/user/emails?ensureAffiliation=true', [userEmailData])
@@ -233,7 +233,7 @@ describe('<EmailsSection />', function () {
       { name: /add another email/i }
     )
 
-    await fetchMock.flush(true)
+    await fetchMock.callHistory.flush(true)
     resetFetchMock()
     fetchMock
       .get('/user/emails?ensureAffiliation=true', [])
@@ -271,8 +271,8 @@ describe('<EmailsSection />', function () {
       name: /add another email/i,
     })
 
-    await fetchMock.flush(true)
-    fetchMock.reset()
+    await fetchMock.callHistory.flush(true)
+    fetchMock.removeRoutes().clearHistory()
     fetchMock.get('express:/institutions/domains', institutionDomainData)
 
     await userEvent.click(button)
@@ -295,7 +295,7 @@ describe('<EmailsSection />', function () {
       name: /add another email/i,
     })
 
-    await fetchMock.flush(true)
+    await fetchMock.callHistory.flush(true)
     resetFetchMock()
 
     await userEvent.click(button)
@@ -330,7 +330,7 @@ describe('<EmailsSection />', function () {
 
     expect(universityInput.disabled).to.be.false
 
-    await fetchMock.flush(true)
+    await fetchMock.callHistory.flush(true)
     resetFetchMock()
 
     // Select the university from dropdown
@@ -364,9 +364,11 @@ describe('<EmailsSection />', function () {
       })
     )
 
-    const [[, request]] = fetchMock.calls(/\/user\/emails/)
+    const request = fetchMock.callHistory.calls(/\/user\/emails/).at(0)
 
-    expect(JSON.parse(request?.body?.toString() || '{}')).to.deep.include({
+    expect(
+      JSON.parse(request?.options.body?.toString() || '{}')
+    ).to.deep.include({
       email: userEmailData.email,
       university: {
         id: userEmailData.affiliation?.institution.id,
@@ -393,7 +395,7 @@ describe('<EmailsSection />', function () {
       name: /add another email/i,
     })
 
-    await fetchMock.flush(true)
+    await fetchMock.callHistory.flush(true)
     resetFetchMock()
 
     fetchMock.get('/institutions/list?country_code=de', [
@@ -446,7 +448,7 @@ describe('<EmailsSection />', function () {
       name: /add another email/i,
     })
 
-    await fetchMock.flush(true)
+    await fetchMock.callHistory.flush(true)
     resetFetchMock()
 
     await userEvent.click(button)
@@ -481,7 +483,7 @@ describe('<EmailsSection />', function () {
 
     expect(universityInput.disabled).to.be.false
 
-    await fetchMock.flush(true)
+    await fetchMock.callHistory.flush(true)
     resetFetchMock()
 
     // Enter the university manually
@@ -516,9 +518,11 @@ describe('<EmailsSection />', function () {
 
     await confirmCodeForEmail(userEmailData.email)
 
-    const [[, request]] = fetchMock.calls(/\/user\/emails/)
+    const request = fetchMock.callHistory.calls(/\/user\/emails/).at(0)
 
-    expect(JSON.parse(request?.body?.toString() || '{}')).to.deep.include({
+    expect(
+      JSON.parse(request?.options.body?.toString() || '{}')
+    ).to.deep.include({
       email: userEmailData.email,
       university: {
         name: newUniversity,
@@ -554,8 +558,8 @@ describe('<EmailsSection />', function () {
       name: /add another email/i,
     })
 
-    await fetchMock.flush(true)
-    fetchMock.reset()
+    await fetchMock.callHistory.flush(true)
+    fetchMock.removeRoutes().clearHistory()
     fetchMock.get(
       `/institutions/domains?hostname=${hostnameFirstChar}&limit=1`,
       institutionDomainDataCopy
@@ -569,8 +573,8 @@ describe('<EmailsSection />', function () {
     )
 
     await userEvent.keyboard('{Tab}')
-    await fetchMock.flush(true)
-    fetchMock.reset()
+    await fetchMock.callHistory.flush(true)
+    fetchMock.removeRoutes().clearHistory()
 
     expect(
       screen.queryByRole('textbox', {
@@ -627,8 +631,8 @@ describe('<EmailsSection />', function () {
       name: /add another email/i,
     })
 
-    await fetchMock.flush(true)
-    fetchMock.reset()
+    await fetchMock.callHistory.flush(true)
+    fetchMock.removeRoutes().clearHistory()
     fetchMock.get(
       `/institutions/domains?hostname=${hostnameFirstChar}&limit=1`,
       institutionDomainDataCopy
@@ -642,8 +646,8 @@ describe('<EmailsSection />', function () {
     )
 
     await userEvent.keyboard('{Tab}')
-    await fetchMock.flush(true)
-    fetchMock.reset()
+    await fetchMock.callHistory.flush(true)
+    fetchMock.removeRoutes().clearHistory()
 
     screen.getByText(institutionDomainDataCopy[0].university.name)
 
@@ -679,8 +683,8 @@ describe('<EmailsSection />', function () {
 
     await confirmCodeForEmail('user@autocomplete.edu')
 
-    await fetchMock.flush(true)
-    fetchMock.reset()
+    await fetchMock.callHistory.flush(true)
+    fetchMock.removeRoutes().clearHistory()
 
     screen.getByText(userEmailDataCopy.affiliation.institution.name, {
       exact: false,

+ 11 - 13
services/web/test/frontend/features/settings/components/emails/emails-section-institution-and-role.test.tsx

@@ -79,12 +79,14 @@ describe('user role and institution', function () {
     Object.assign(getMeta('ol-ExposedSettings'), {
       hasAffiliationsFeature: true,
     })
-    fetchMock.reset()
-    fetchMock.get('/user/emails?ensureAffiliation=true', [])
+    fetchMock.removeRoutes().clearHistory()
+    fetchMock.get('/user/emails?ensureAffiliation=true', [], {
+      name: 'get user emails',
+    })
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('renders affiliation name with add role/department button', function () {
@@ -122,13 +124,11 @@ describe('user role and institution', function () {
 
   it('fetches institution data and replaces departments dropdown on add/change', async function () {
     const userEmailData = userData1
-    fetchMock.get('/user/emails?ensureAffiliation=true', [userEmailData], {
-      overwriteRoutes: true,
-    })
+    fetchMock.modifyRoute('get user emails', { response: [userEmailData] })
     render(<EmailsSection />)
 
-    await fetchMock.flush(true)
-    fetchMock.reset()
+    await fetchMock.callHistory.flush(true)
+    fetchMock.removeRoutes().clearHistory()
 
     const fakeDepartment = 'Fake department'
     const institution = userEmailData.affiliation.institution
@@ -143,8 +143,8 @@ describe('user role and institution', function () {
       screen.getByRole('button', { name: /add role and department/i })
     )
 
-    await fetchMock.flush(true)
-    fetchMock.reset()
+    await fetchMock.callHistory.flush(true)
+    fetchMock.removeRoutes().clearHistory()
 
     fireEvent.click(screen.getByRole('textbox', { name: /department/i }))
 
@@ -153,9 +153,7 @@ describe('user role and institution', function () {
 
   it('adds new role and department', async function () {
     fetchMock
-      .get('/user/emails?ensureAffiliation=true', [userData1], {
-        overwriteRoutes: true,
-      })
+      .modifyRoute('get user emails', { response: [userData1] })
       .get(/\/institutions\/list/, { departments: [] })
       .post('/user/emails/endorse', 200)
     render(<EmailsSection />)

+ 2 - 2
services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx

@@ -22,11 +22,11 @@ describe('<EmailsSection />', function () {
     Object.assign(getMeta('ol-ExposedSettings'), {
       hasAffiliationsFeature: true,
     })
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('renders translated heading', function () {

+ 4 - 4
services/web/test/frontend/features/settings/components/emails/reconfirmation-info.test.tsx

@@ -42,7 +42,7 @@ describe('<ReconfirmationInfo/>', function () {
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
     this.locationStub.restore()
   })
 
@@ -145,13 +145,13 @@ describe('<ReconfirmationInfo/>', function () {
         await waitFor(() => {
           expect(confirmButton.disabled).to.be.true
         })
-        expect(fetchMock.called()).to.be.true
+        expect(fetchMock.callHistory.called()).to.be.true
 
         // the confirmation text should now be displayed
         await screen.findByText(/Please check your email inbox to confirm/)
 
         // try the resend button
-        fetchMock.resetHistory()
+        fetchMock.clearHistory()
         const resendButton = await screen.findByRole('button', {
           name: 'Resend confirmation email',
         })
@@ -160,7 +160,7 @@ describe('<ReconfirmationInfo/>', function () {
 
         // commented out as it's already gone by this point
         // await screen.findByText(/Sending/)
-        expect(fetchMock.called()).to.be.true
+        expect(fetchMock.callHistory.called()).to.be.true
         await waitForElementToBeRemoved(() => screen.getByText('Sending…'))
         await screen.findByRole('button', {
           name: 'Resend confirmation email',

+ 1 - 1
services/web/test/frontend/features/settings/components/leave/modal-content.test.tsx

@@ -12,7 +12,7 @@ describe('<LeaveModalContent />', function () {
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('disable delete button if form is not valid', function () {

+ 6 - 6
services/web/test/frontend/features/settings/components/leave/modal-form.test.tsx

@@ -1,7 +1,7 @@
 import { expect } from 'chai'
 import sinon from 'sinon'
 import { fireEvent, screen, render, waitFor } from '@testing-library/react'
-import fetchMock, { FetchMockStatic } from 'fetch-mock'
+import fetchMock, { type FetchMock } from 'fetch-mock'
 
 import LeaveModalForm from '../../../../../../frontend/js/features/settings/components/leave/modal-form'
 import * as useLocationModule from '../../../../../../frontend/js/shared/hooks/use-location'
@@ -14,7 +14,7 @@ describe('<LeaveModalForm />', function () {
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('validates form', async function () {
@@ -50,7 +50,7 @@ describe('<LeaveModalForm />', function () {
   describe('submits', async function () {
     let setInFlight: sinon.SinonStub
     let setIsFormValid: sinon.SinonStub
-    let deleteMock: FetchMockStatic
+    let deleteMock: FetchMock
     let assignStub: sinon.SinonStub
 
     beforeEach(function () {
@@ -68,7 +68,7 @@ describe('<LeaveModalForm />', function () {
     })
 
     afterEach(function () {
-      fetchMock.reset()
+      fetchMock.removeRoutes().clearHistory()
       this.locationStub.restore()
     })
 
@@ -85,7 +85,7 @@ describe('<LeaveModalForm />', function () {
 
       sinon.assert.calledOnce(setInFlight)
       sinon.assert.calledWithMatch(setInFlight, true)
-      expect(deleteMock.called()).to.be.true
+      expect(deleteMock.callHistory.called()).to.be.true
       await waitFor(() => {
         sinon.assert.calledTwice(setInFlight)
         sinon.assert.calledWithMatch(setInFlight, false)
@@ -105,7 +105,7 @@ describe('<LeaveModalForm />', function () {
 
       fireEvent.submit(screen.getByLabelText('Email'))
 
-      expect(deleteMock.called()).to.be.false
+      expect(deleteMock.callHistory.called()).to.be.false
       sinon.assert.notCalled(setInFlight)
     })
   })

+ 1 - 1
services/web/test/frontend/features/settings/components/leave/modal.test.tsx

@@ -13,7 +13,7 @@ describe('<LeaveModal />', function () {
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('closes modal on cancel', async function () {

+ 1 - 1
services/web/test/frontend/features/settings/components/leavers-survey-alert.test.tsx

@@ -21,7 +21,7 @@ describe('<LeaversSurveyAlert/>', function () {
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('should render before the expiration date', function () {

+ 1 - 1
services/web/test/frontend/features/settings/components/linking-section.test.tsx

@@ -52,7 +52,7 @@ describe('<LinkingSection />', function () {
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('shows header', async function () {

+ 10 - 10
services/web/test/frontend/features/settings/components/password-section.test.tsx

@@ -17,7 +17,7 @@ describe('<PasswordSection />', function () {
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('shows password managed externally message', async function () {
@@ -45,14 +45,14 @@ describe('<PasswordSection />', function () {
     render(<PasswordSection />)
     submitValidForm()
 
-    expect(updateMock.called()).to.be.true
-    expect(JSON.parse(updateMock.lastCall()![1]!.body as string)).to.deep.equal(
-      {
-        currentPassword: 'foobar',
-        newPassword1: 'barbaz',
-        newPassword2: 'barbaz',
-      }
-    )
+    expect(updateMock.callHistory.called()).to.be.true
+    expect(
+      JSON.parse(updateMock.callHistory.calls().at(-1)?.options.body as string)
+    ).to.deep.equal({
+      currentPassword: 'foobar',
+      newPassword1: 'barbaz',
+      newPassword2: 'barbaz',
+    })
   })
 
   it('disables button on invalid form', async function () {
@@ -64,7 +64,7 @@ describe('<PasswordSection />', function () {
         name: 'Change',
       })
     )
-    expect(updateMock.called()).to.be.false
+    expect(updateMock.callHistory.called()).to.be.false
   })
 
   it('validates inputs', async function () {

+ 1 - 1
services/web/test/frontend/features/settings/components/security-section.test.tsx

@@ -5,7 +5,7 @@ import fetchMock from 'fetch-mock'
 
 describe('<SecuritySection />', function () {
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('shows Group SSO rows in security section', async function () {

+ 3 - 3
services/web/test/frontend/features/settings/context/sso-context.test.tsx

@@ -35,7 +35,7 @@ describe('SSOContext', function () {
       google: 'google-id',
     })
     window.metaAttributesCache.set('ol-oauthProviders', mockOauthProviders)
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('should initialise subscriptions with their linked status', function () {
@@ -69,13 +69,13 @@ describe('SSOContext', function () {
     it('when the provider is not linked, should do nothing', function () {
       const { result } = renderSSOContext()
       result.current.unlink('orcid')
-      expect(fetchMock.called()).to.be.false
+      expect(fetchMock.callHistory.called()).to.be.false
     })
 
     it('supports unmounting the component while the request is inflight', async function () {
       const { result, unmount } = renderSSOContext()
       result.current.unlink('google')
-      expect(fetchMock.called()).to.be.true
+      expect(fetchMock.callHistory.called()).to.be.true
       unmount()
     })
   })

+ 24 - 20
services/web/test/frontend/features/settings/context/user-email-context.test.tsx

@@ -1,6 +1,8 @@
 import { expect } from 'chai'
 import { cloneDeep } from 'lodash'
 import { renderHook } from '@testing-library/react-hooks'
+import { waitFor } from '@testing-library/react'
+
 import {
   EmailContextType,
   UserEmailsProvider,
@@ -26,7 +28,7 @@ const renderUserEmailsContext = () =>
 
 describe('UserEmailContext', function () {
   beforeEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   describe('context bootstrap', function () {
@@ -49,8 +51,8 @@ describe('UserEmailContext', function () {
     it('should load all user emails and update the initialisation state to "success"', async function () {
       fetchMock.get(/\/user\/emails/, fakeUsersData)
       const { result } = renderUserEmailsContext()
-      await fetchMock.flush(true)
-      expect(fetchMock.calls()).to.have.lengthOf(1)
+      await fetchMock.callHistory.flush(true)
+      expect(fetchMock.callHistory.calls()).to.have.lengthOf(1)
       expect(result.current.state.data.byId).to.deep.equal({
         'bar@overleaf.com': { ...untrustedUserData, ...confirmedUserData },
         'baz@overleaf.com': unconfirmedUserData,
@@ -66,10 +68,12 @@ describe('UserEmailContext', function () {
     it('when loading user email fails, it should update the initialisation state to "failed"', async function () {
       fetchMock.get(/\/user\/emails/, 500)
       const { result } = renderUserEmailsContext()
-      await fetchMock.flush()
+      await fetchMock.callHistory.flush()
 
-      expect(result.current.isInitializing).to.equal(false)
-      expect(result.current.isInitializingError).to.equal(true)
+      await waitFor(() => {
+        expect(result.current.isInitializing).to.equal(false)
+        expect(result.current.isInitializingError).to.equal(true)
+      })
     })
 
     describe('state.isLoading', function () {
@@ -94,12 +98,12 @@ describe('UserEmailContext', function () {
       fetchMock.get(/\/user\/emails/, fakeUsersData)
       const value = renderUserEmailsContext()
       result = value.result
-      await fetchMock.flush(true)
+      await fetchMock.callHistory.flush(true)
     })
 
     describe('getEmails()', function () {
       beforeEach(async function () {
-        fetchMock.reset()
+        fetchMock.removeRoutes().clearHistory()
       })
 
       it('should set `isLoading === true`', function () {
@@ -120,7 +124,7 @@ describe('UserEmailContext', function () {
         }
         fetchMock.get(/\/user\/emails/, [emailData])
         result.current.getEmails()
-        await fetchMock.flush(true)
+        await fetchMock.callHistory.flush(true)
         expect(result.current.state.data.byId).to.deep.equal({
           'new@email.com': emailData,
         })
@@ -133,7 +137,7 @@ describe('UserEmailContext', function () {
           { ...professionalUserData, samlProviderId: 'saml_provider_2' },
         ])
         const { result } = renderUserEmailsContext()
-        await fetchMock.flush(true)
+        await fetchMock.callHistory.flush(true)
         expect(result.current.state.data.linkedInstitutionIds).to.deep.equal([
           'saml_provider_1',
           'saml_provider_2',
@@ -257,11 +261,11 @@ describe('UserEmailContext', function () {
         const affiliatedEmail2 = cloneDeep(professionalUserData)
         affiliatedEmail2.emailHasInstitutionLicence = true
 
-        fetchMock.reset()
+        fetchMock.removeRoutes().clearHistory()
         fetchMock.get(/\/user\/emails/, [affiliatedEmail1, affiliatedEmail2])
 
         result.current.getEmails()
-        await fetchMock.flush(true)
+        await fetchMock.callHistory.flush(true)
 
         // `resetLeaversSurveyExpiration` always happens after deletion
         result.current.deleteEmail(affiliatedEmail1.email)
@@ -281,11 +285,11 @@ describe('UserEmailContext', function () {
         const affiliatedEmail2 = cloneDeep(professionalUserData)
         affiliatedEmail2.emailHasInstitutionLicence = true
 
-        fetchMock.reset()
+        fetchMock.removeRoutes().clearHistory()
         fetchMock.get(/\/user\/emails/, [affiliatedEmail1, affiliatedEmail2])
 
         result.current.getEmails()
-        await fetchMock.flush(true)
+        await fetchMock.callHistory.flush(true)
 
         // `resetLeaversSurveyExpiration` always happens after deletion
         result.current.deleteEmail(affiliatedEmail1.email)
@@ -303,11 +307,11 @@ describe('UserEmailContext', function () {
         affiliatedEmail1.email = 'institution-test@example.com'
         affiliatedEmail1.affiliation.pastReconfirmDate = true
 
-        fetchMock.reset()
+        fetchMock.removeRoutes().clearHistory()
         fetchMock.get(/\/user\/emails/, [confirmedUserData, affiliatedEmail1])
 
         result.current.getEmails()
-        await fetchMock.flush(true)
+        await fetchMock.callHistory.flush(true)
 
         // `resetLeaversSurveyExpiration` always happens after deletion
         result.current.deleteEmail(affiliatedEmail1.email)
@@ -323,11 +327,11 @@ describe('UserEmailContext', function () {
         emailWithInstitutionLicense.email = 'institution-licensed@example.com'
         emailWithInstitutionLicense.emailHasInstitutionLicence = false
 
-        fetchMock.reset()
+        fetchMock.removeRoutes().clearHistory()
         fetchMock.get(/\/user\/emails/, [emailWithInstitutionLicense])
 
         result.current.getEmails()
-        await fetchMock.flush(true)
+        await fetchMock.callHistory.flush(true)
 
         // `resetLeaversSurveyExpiration` always happens after deletion
         result.current.deleteEmail(emailWithInstitutionLicense.email)
@@ -342,11 +346,11 @@ describe('UserEmailContext', function () {
         emailWithInstitutionLicense.email = 'institution-licensed@example.com'
         emailWithInstitutionLicense.affiliation.pastReconfirmDate = false
 
-        fetchMock.reset()
+        fetchMock.removeRoutes().clearHistory()
         fetchMock.get(/\/user\/emails/, [emailWithInstitutionLicense])
 
         result.current.getEmails()
-        await fetchMock.flush(true)
+        await fetchMock.callHistory.flush(true)
 
         // `resetLeaversSurveyExpiration` always happens after deletion
         result.current.deleteEmail(emailWithInstitutionLicense.email)

+ 52 - 49
services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx

@@ -102,7 +102,7 @@ describe('<ShareProjectModal/>', function () {
 
   afterEach(function () {
     this.locationStub.restore()
-    fetchMock.restore()
+    fetchMock.removeRoutes().clearHistory()
     cleanUpContext()
   })
 
@@ -414,7 +414,7 @@ describe('<ShareProjectModal/>', function () {
 
     await waitFor(() => expect(closeButton.disabled).to.be.true)
 
-    expect(fetchMock.done()).to.be.true
+    expect(fetchMock.callHistory.done()).to.be.true
     expect(closeButton.disabled).to.be.false
   })
 
@@ -447,7 +447,7 @@ describe('<ShareProjectModal/>', function () {
     fireEvent.click(revokeButton)
     await waitFor(() => expect(closeButton.disabled).to.be.true)
 
-    expect(fetchMock.done()).to.be.true
+    expect(fetchMock.callHistory.done()).to.be.true
     expect(closeButton.disabled).to.be.false
   })
 
@@ -484,10 +484,10 @@ describe('<ShareProjectModal/>', function () {
 
     await waitFor(() => expect(closeButton.disabled).to.be.true)
 
-    const { body } = fetchMock.lastOptions()
+    const { body } = fetchMock.callHistory.calls().at(-1).options
     expect(JSON.parse(body)).to.deep.equal({ privilegeLevel: 'readAndWrite' })
 
-    expect(fetchMock.done()).to.be.true
+    expect(fetchMock.callHistory.done()).to.be.true
     expect(closeButton.disabled).to.be.false
   })
 
@@ -525,10 +525,12 @@ describe('<ShareProjectModal/>', function () {
     })
     fireEvent.click(removeButton)
 
-    const url = fetchMock.lastUrl()
-    expect(url).to.equal('/project/test-project/users/member-viewer')
+    const url = fetchMock.callHistory.calls().at(-1).url
+    expect(url).to.equal(
+      'https://www.test-overleaf.com/project/test-project/users/member-viewer'
+    )
 
-    expect(fetchMock.done()).to.be.true
+    expect(fetchMock.callHistory.done()).to.be.true
   })
 
   it('changes member privileges to owner with confirmation', async function () {
@@ -573,10 +575,10 @@ describe('<ShareProjectModal/>', function () {
     fireEvent.click(confirmButton)
     await waitFor(() => expect(confirmButton.disabled).to.be.true)
 
-    const { body } = fetchMock.lastOptions()
+    const { body } = fetchMock.callHistory.calls().at(-1).options
     expect(JSON.parse(body)).to.deep.equal({ user_id: 'member-viewer' })
 
-    expect(fetchMock.done()).to.be.true
+    expect(fetchMock.callHistory.done()).to.be.true
   })
 
   it('sends invites to input email addresses', async function () {
@@ -593,7 +595,7 @@ describe('<ShareProjectModal/>', function () {
 
     // loading contacts
     await waitFor(() => {
-      expect(fetchMock.called('express:/user/contacts')).to.be.true
+      expect(fetchMock.callHistory.called('express:/user/contacts')).to.be.true
     })
 
     // displaying a list of matching contacts
@@ -604,26 +606,29 @@ describe('<ShareProjectModal/>', function () {
 
     // sending invitations
 
-    fetchMock.post('express:/project/:projectId/invite', (url, req) => {
-      const data = JSON.parse(req.body)
-
-      if (data.email === 'a@b.c') {
-        return {
-          status: 400,
-          body: { errorReason: 'invalid_email' },
+    fetchMock.post(
+      'express:/project/:projectId/invite',
+      ({ args: [url, req] }) => {
+        const data = JSON.parse(req.body)
+
+        if (data.email === 'a@b.c') {
+          return {
+            status: 400,
+            body: { errorReason: 'invalid_email' },
+          }
         }
-      }
 
-      return {
-        status: 200,
-        body: {
-          invite: {
-            ...data,
-            _id: data.email,
+        return {
+          status: 200,
+          body: {
+            invite: {
+              ...data,
+              _id: data.email,
+            },
           },
-        },
+        }
       }
-    })
+    )
 
     fireEvent.paste(inputElement, {
       clipboardData: {
@@ -643,22 +648,24 @@ describe('<ShareProjectModal/>', function () {
     let calls
     await waitFor(
       () => {
-        calls = fetchMock.calls('express:/project/:projectId/invite')
+        calls = fetchMock.callHistory.calls(
+          'express:/project/:projectId/invite'
+        )
         expect(calls).to.have.length(4)
       },
       { timeout: 5000 } // allow time for delay between each request
     )
 
-    expect(calls[0][1].body).to.equal(
+    expect(calls[0].args[1].body).to.equal(
       JSON.stringify({ email: 'test@example.com', privileges: 'readOnly' })
     )
-    expect(calls[1][1].body).to.equal(
+    expect(calls[1].args[1].body).to.equal(
       JSON.stringify({ email: 'foo@example.com', privileges: 'readOnly' })
     )
-    expect(calls[2][1].body).to.equal(
+    expect(calls[2].args[1].body).to.equal(
       JSON.stringify({ email: 'bar@example.com', privileges: 'readOnly' })
     )
-    expect(calls[3][1].body).to.equal(
+    expect(calls[3].args[1].body).to.equal(
       JSON.stringify({ email: 'a@b.c', privileges: 'readOnly' })
     )
 
@@ -752,7 +759,7 @@ describe('<ShareProjectModal/>', function () {
 
     // loading contacts
     await waitFor(() => {
-      expect(fetchMock.called('express:/user/contacts')).to.be.true
+      expect(fetchMock.callHistory.called('express:/user/contacts')).to.be.true
     })
 
     const [inputElement] = await screen.findAllByLabelText('Add people')
@@ -766,19 +773,15 @@ describe('<ShareProjectModal/>', function () {
       })
       fireEvent.blur(inputElement)
 
-      fetchMock.postOnce(
-        'express:/project/:projectId/invite',
-        {
-          status: 400,
-          body: { errorReason },
-        },
-        { overwriteRoutes: true }
-      )
+      fetchMock.postOnce('express:/project/:projectId/invite', {
+        status: 400,
+        body: { errorReason },
+      })
 
       expect(submitButton.disabled).to.be.false
       await userEvent.click(submitButton)
-      await fetchMock.flush(true)
-      expect(fetchMock.done()).to.be.true
+      await fetchMock.callHistory.flush(true)
+      expect(fetchMock.callHistory.done()).to.be.true
     }
 
     await respondWithError('cannot_invite_non_user')
@@ -820,7 +823,7 @@ describe('<ShareProjectModal/>', function () {
     fireEvent.click(enableButton)
     await waitFor(() => expect(enableButton.disabled).to.be.true)
 
-    const { body: tokenBody } = fetchMock.lastOptions()
+    const { body: tokenBody } = fetchMock.callHistory.calls().at(-1).options
     expect(JSON.parse(tokenBody)).to.deep.equal({
       publicAccessLevel: 'tokenBased',
     })
@@ -840,7 +843,7 @@ describe('<ShareProjectModal/>', function () {
     fireEvent.click(disableButton)
     await waitFor(() => expect(disableButton.disabled).to.be.true)
 
-    const { body: privateBody } = fetchMock.lastOptions()
+    const { body: privateBody } = fetchMock.callHistory.calls().at(-1).options
     expect(JSON.parse(privateBody)).to.deep.equal({
       publicAccessLevel: 'private',
     })
@@ -865,7 +868,7 @@ describe('<ShareProjectModal/>', function () {
 
     // Wait for contacts to load
     await waitFor(() => {
-      expect(fetchMock.called('express:/user/contacts')).to.be.true
+      expect(fetchMock.callHistory.called('express:/user/contacts')).to.be.true
     })
 
     // Enter a prefix that matches a contact
@@ -919,7 +922,7 @@ describe('<ShareProjectModal/>', function () {
 
     // Wait for contacts to load
     await waitFor(() => {
-      expect(fetchMock.called('express:/user/contacts')).to.be.true
+      expect(fetchMock.callHistory.called('express:/user/contacts')).to.be.true
     })
 
     // Enter a prefix that matches a contact
@@ -954,7 +957,7 @@ describe('<ShareProjectModal/>', function () {
 
     // Wait for contacts to load
     await waitFor(() => {
-      expect(fetchMock.called('express:/user/contacts')).to.be.true
+      expect(fetchMock.callHistory.called('express:/user/contacts')).to.be.true
     })
 
     // Enter a prefix that matches a contact
@@ -988,7 +991,7 @@ describe('<ShareProjectModal/>', function () {
 
     // Wait for contacts to load
     await waitFor(() => {
-      expect(fetchMock.called('express:/user/contacts')).to.be.true
+      expect(fetchMock.callHistory.called('express:/user/contacts')).to.be.true
     })
 
     // Enter a prefix that matches a contact

+ 2 - 2
services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx

@@ -51,7 +51,7 @@ describe('<GroupSubscriptionMemberships />', function () {
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('renders all group subscriptions not managed', function () {
@@ -129,7 +129,7 @@ describe('<GroupSubscriptionMemberships />', function () {
 
       fireEvent.click(this.leaveNowButton)
 
-      expect(leaveGroupApiMock.called()).to.be.true
+      expect(leaveGroupApiMock.callHistory.called()).to.be.true
       await waitFor(() => {
         expect(reloadStub).to.have.been.called
       })

+ 10 - 7
services/web/test/frontend/features/subscription/components/dashboard/managed-institutions.test.tsx

@@ -37,7 +37,7 @@ describe('<ManagedInstitutions />', function () {
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('renders all managed institutions', function () {
@@ -98,7 +98,9 @@ describe('<ManagedInstitutions />', function () {
 
     const unsubscribeLink = screen.getByText('Unsubscribe')
     await fireEvent.click(unsubscribeLink)
-    await waitFor(() => expect(fetchMock.called(unsubscribeUrl)).to.be.true)
+    await waitFor(
+      () => expect(fetchMock.callHistory.called(unsubscribeUrl)).to.be.true
+    )
 
     await waitFor(() => {
       expect(screen.getByText('Subscribe')).to.exist
@@ -122,13 +124,14 @@ describe('<ManagedInstitutions />', function () {
       </SplitTestProvider>
     )
 
-    const subscribeLink = screen.getByText('Subscribe')
+    const subscribeLink = await screen.findByText('Subscribe')
+
     await fireEvent.click(subscribeLink)
-    await waitFor(() => expect(fetchMock.called(subscribeUrl)).to.be.true)
+    await waitFor(
+      () => expect(fetchMock.callHistory.called(subscribeUrl)).to.be.true
+    )
 
-    await waitFor(() => {
-      expect(screen.getByText('Unsubscribe')).to.exist
-    })
+    await screen.findByText('Unsubscribe')
   })
 
   it('renders nothing when there are no institutions', function () {

+ 1 - 1
services/web/test/frontend/features/subscription/components/dashboard/managed-publishers.test.tsx

@@ -28,7 +28,7 @@ describe('<ManagedPublishers />', function () {
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('renders all managed publishers', function () {

+ 1 - 1
services/web/test/frontend/features/subscription/components/dashboard/pause-modal.test.tsx

@@ -59,7 +59,7 @@ describe('<PauseSubscriptionModal />', function () {
   })
 
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
     this.locationStub.restore()
     this.replaceStateStub.restore()
   })

+ 4 - 4
services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx

@@ -106,18 +106,18 @@ describe('<PersonalSubscription />', function () {
       fetchMock.postOnce(reactivateSubscriptionUrl, 400)
       fireEvent.click(reactivateBtn)
       expect(reactivateBtn.disabled).to.be.true
-      await fetchMock.flush(true)
+      await fetchMock.callHistory.flush(true)
       expect(reactivateBtn.disabled).to.be.false
       expect(reloadStub).not.to.have.been.called
-      fetchMock.reset()
+      fetchMock.removeRoutes().clearHistory()
 
       // 2nd click - success
       fetchMock.postOnce(reactivateSubscriptionUrl, 200)
       fireEvent.click(reactivateBtn)
-      await fetchMock.flush(true)
+      await fetchMock.callHistory.flush(true)
       expect(reloadStub).to.have.been.calledOnce
       expect(reactivateBtn.disabled).to.be.true
-      fetchMock.reset()
+      fetchMock.removeRoutes().clearHistory()
     })
 
     it('renders the expired dash', function () {

+ 1 - 1
services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx

@@ -208,7 +208,7 @@ describe('<ActiveSubscription />', function () {
 
     afterEach(function () {
       this.locationStub.restore()
-      fetchMock.reset()
+      fetchMock.removeRoutes().clearHistory()
     })
 
     function showConfirmCancelUI() {

+ 1 - 1
services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx

@@ -38,7 +38,7 @@ describe('<ChangePlanModal />', function () {
 
   afterEach(function () {
     cleanUpContext()
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
     this.locationStub.restore()
   })
 

+ 1 - 1
services/web/test/frontend/features/subscription/components/group-invite/has-individual-recurly-subscription.test.tsx

@@ -7,7 +7,7 @@ import fetchMock from 'fetch-mock'
 describe('group invite', function () {
   describe('user has a personal subscription', function () {
     afterEach(function () {
-      fetchMock.reset()
+      fetchMock.removeRoutes().clearHistory()
     })
 
     it('shows option to cancel subscription', async function () {

+ 1 - 1
services/web/test/frontend/features/subscription/components/group-invite/join-group.test.tsx

@@ -10,7 +10,7 @@ describe('join group', function () {
     window.metaAttributesCache.set('ol-inviteToken', inviteToken)
   })
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   it('shows option to join subscription', async function () {

+ 1 - 1
services/web/test/frontend/features/subscription/helpers/render-with-subscription-dash-context.tsx

@@ -90,5 +90,5 @@ export function renderWithSubscriptionDashContext(
 export function cleanUpContext() {
   // @ts-ignore
   delete global.recurly
-  fetchMock.reset()
+  fetchMock.removeRoutes().clearHistory()
 }

+ 1 - 1
services/web/test/frontend/features/word-count-modal/components/word-count-modal.test.jsx

@@ -7,7 +7,7 @@ import WordCountModal from '../../../../../frontend/js/features/word-count-modal
 
 describe('<WordCountModal />', function () {
   afterEach(function () {
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   const contextProps = {

+ 3 - 14
services/web/test/frontend/infrastructure/fetch-json.test.js

@@ -1,6 +1,5 @@
 import { expect } from 'chai'
 import fetchMock from 'fetch-mock'
-import { Response } from 'node-fetch'
 import {
   deleteJSON,
   FetchError,
@@ -12,11 +11,11 @@ import {
 
 describe('fetchJSON', function () {
   before(function () {
-    fetchMock.restore()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   afterEach(function () {
-    fetchMock.restore()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   const headers = {
@@ -138,17 +137,7 @@ describe('fetchJSON', function () {
   })
 
   it('handles 5xx responses without a status message', async function () {
-    // It's hard to make a Response object with statusText=null,
-    // so we need to do some monkey-work to make it happen
-    const response = new Response('weird scary error', {
-      ok: false,
-      status: 599,
-    })
-    Object.defineProperty(response, 'statusText', {
-      get: () => null,
-      set: () => {},
-    })
-    fetchMock.get('/test', response)
+    fetchMock.get('/test', { status: 599 })
 
     return expect(getJSON('/test'))
       .to.eventually.be.rejectedWith('Unexpected Error: 599')

+ 33 - 33
services/web/test/frontend/infrastructure/project-snapshot.test.ts

@@ -139,7 +139,7 @@ describe('ProjectSnapshot', function () {
     mockLatestChunk()
     mockBlobs(['main.tex', 'hello.txt'])
     await snapshot.refresh()
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   }
 
   describe('after initialization', function () {
@@ -176,7 +176,7 @@ describe('ProjectSnapshot', function () {
     mockChanges()
     mockBlobs(['goodbye.txt'])
     await snapshot.refresh()
-    fetchMock.reset()
+    fetchMock.removeRoutes().clearHistory()
   }
 
   describe('after refresh', function () {
@@ -184,7 +184,7 @@ describe('ProjectSnapshot', function () {
     beforeEach(refreshSnapshot)
 
     afterEach(function () {
-      fetchMock.reset()
+      fetchMock.removeRoutes().clearHistory()
     })
 
     describe('getDocPaths()', function () {
@@ -214,7 +214,7 @@ describe('ProjectSnapshot', function () {
 
   describe('concurrency', function () {
     afterEach(function () {
-      fetchMock.reset()
+      fetchMock.removeRoutes().clearHistory()
     })
 
     specify('two concurrent inits', async function () {
@@ -226,9 +226,9 @@ describe('ProjectSnapshot', function () {
       await Promise.all([snapshot.refresh(), snapshot.refresh()])
 
       // The first request initializes, the second request loads changes
-      expect(fetchMock.calls('flush')).to.have.length(2)
-      expect(fetchMock.calls('latest-chunk')).to.have.length(1)
-      expect(fetchMock.calls('changes-1')).to.have.length(1)
+      expect(fetchMock.callHistory.calls('flush')).to.have.length(2)
+      expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
+      expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
     })
 
     specify('three concurrent inits', async function () {
@@ -245,9 +245,9 @@ describe('ProjectSnapshot', function () {
 
       // The first request initializes, the second and third are combined and
       // load changes
-      expect(fetchMock.calls('flush')).to.have.length(2)
-      expect(fetchMock.calls('latest-chunk')).to.have.length(1)
-      expect(fetchMock.calls('changes-1')).to.have.length(1)
+      expect(fetchMock.callHistory.calls('flush')).to.have.length(2)
+      expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
+      expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
     })
 
     specify('two concurrent inits - first fails', async function () {
@@ -262,9 +262,9 @@ describe('ProjectSnapshot', function () {
 
       // The first init fails, but the second succeeds
       expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
-      expect(fetchMock.calls('flush')).to.have.length(2)
-      expect(fetchMock.calls('latest-chunk')).to.have.length(1)
-      expect(fetchMock.calls('changes-1')).to.have.length(0)
+      expect(fetchMock.callHistory.calls('flush')).to.have.length(2)
+      expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
+      expect(fetchMock.callHistory.calls('changes-1')).to.have.length(0)
     })
 
     specify('three concurrent inits - second fails', async function () {
@@ -285,10 +285,10 @@ describe('ProjectSnapshot', function () {
       // The first init succeeds, the two queued requests fail, the last request
       // succeeds
       expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
-      expect(fetchMock.calls('flush')).to.have.length(3)
-      expect(fetchMock.calls('latest-chunk')).to.have.length(1)
-      expect(fetchMock.calls('changes-1')).to.have.length(1)
-      expect(fetchMock.calls('changes-2')).to.have.length(0)
+      expect(fetchMock.callHistory.calls('flush')).to.have.length(3)
+      expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
+      expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
+      expect(fetchMock.callHistory.calls('changes-2')).to.have.length(0)
     })
 
     specify('two concurrent load changes', async function () {
@@ -304,10 +304,10 @@ describe('ProjectSnapshot', function () {
       await Promise.all([snapshot.refresh(), snapshot.refresh()])
 
       // One init, two load changes
-      expect(fetchMock.calls('flush')).to.have.length(3)
-      expect(fetchMock.calls('latest-chunk')).to.have.length(1)
-      expect(fetchMock.calls('changes-1')).to.have.length(1)
-      expect(fetchMock.calls('changes-2')).to.have.length(1)
+      expect(fetchMock.callHistory.calls('flush')).to.have.length(3)
+      expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
+      expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
+      expect(fetchMock.callHistory.calls('changes-2')).to.have.length(1)
     })
 
     specify('three concurrent load changes', async function () {
@@ -327,10 +327,10 @@ describe('ProjectSnapshot', function () {
       ])
 
       // One init, two load changes (the two last are queued and combined)
-      expect(fetchMock.calls('flush')).to.have.length(3)
-      expect(fetchMock.calls('latest-chunk')).to.have.length(1)
-      expect(fetchMock.calls('changes-1')).to.have.length(1)
-      expect(fetchMock.calls('changes-2')).to.have.length(1)
+      expect(fetchMock.callHistory.calls('flush')).to.have.length(3)
+      expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
+      expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
+      expect(fetchMock.callHistory.calls('changes-2')).to.have.length(1)
     })
 
     specify('two concurrent load changes - first fails', async function () {
@@ -350,10 +350,10 @@ describe('ProjectSnapshot', function () {
 
       // One init, one load changes fails, the second succeeds
       expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
-      expect(fetchMock.calls('flush')).to.have.length(3)
-      expect(fetchMock.calls('latest-chunk')).to.have.length(1)
-      expect(fetchMock.calls('changes-1')).to.have.length(1)
-      expect(fetchMock.calls('changes-2')).to.have.length(0)
+      expect(fetchMock.callHistory.calls('flush')).to.have.length(3)
+      expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
+      expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
+      expect(fetchMock.callHistory.calls('changes-2')).to.have.length(0)
     })
 
     specify('three concurrent load changes - second fails', async function () {
@@ -378,10 +378,10 @@ describe('ProjectSnapshot', function () {
       // One init, one load changes succeeds, the second and third are combined
       // and fail, the last request succeeds
       expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
-      expect(fetchMock.calls('flush')).to.have.length(4)
-      expect(fetchMock.calls('latest-chunk')).to.have.length(1)
-      expect(fetchMock.calls('changes-1')).to.have.length(1)
-      expect(fetchMock.calls('changes-2')).to.have.length(1)
+      expect(fetchMock.callHistory.calls('flush')).to.have.length(4)
+      expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
+      expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
+      expect(fetchMock.callHistory.calls('changes-2')).to.have.length(1)
     })
   })
 })

+ 4 - 4
services/web/test/frontend/shared/hooks/use-abort-controller.test.tsx

@@ -13,7 +13,7 @@ describe('useAbortController', function () {
   }
 
   beforeEach(function () {
-    fetchMock.restore()
+    fetchMock.removeRoutes().clearHistory()
 
     status = {
       loading: false,
@@ -23,7 +23,7 @@ describe('useAbortController', function () {
   })
 
   after(function () {
-    fetchMock.restore()
+    fetchMock.removeRoutes().clearHistory()
   })
 
   function AbortableRequest({ url }: { url: string }) {
@@ -80,8 +80,8 @@ describe('useAbortController', function () {
 
     unmount()
 
-    await fetchMock.flush(true)
-    expect(fetchMock.done()).to.be.true
+    await fetchMock.callHistory.flush(true)
+    expect(fetchMock.callHistory.done()).to.be.true
 
     // wait for Promises to be resolved
     await new Promise(resolve => setTimeout(resolve, 0))