notifications.test.tsx 37 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060
  1. import { expect } from 'chai'
  2. import sinon from 'sinon'
  3. import {
  4. fireEvent,
  5. render,
  6. screen,
  7. waitForElementToBeRemoved,
  8. within,
  9. } from '@testing-library/react'
  10. import fetchMock from 'fetch-mock'
  11. import { merge, cloneDeep } from 'lodash'
  12. import {
  13. professionalUserData,
  14. unconfirmedUserData,
  15. unconfirmedCommonsUserData,
  16. } from '../../settings/fixtures/test-user-email-data'
  17. import {
  18. notificationDropboxDuplicateProjectNames,
  19. notificationGroupInviteDefault,
  20. notificationIPMatchedAffiliation,
  21. notificationProjectInvite,
  22. notificationTPDSFileLimit,
  23. notificationsInstitution,
  24. } from '../fixtures/notifications-data'
  25. import Common from '../../../../../frontend/js/features/project-list/components/notifications/groups/common'
  26. import Institution from '../../../../../frontend/js/features/project-list/components/notifications/groups/institution'
  27. import ConfirmEmail from '../../../../../frontend/js/features/project-list/components/notifications/groups/confirm-email'
  28. import ReconfirmationInfo from '../../../../../frontend/js/features/project-list/components/notifications/groups/affiliation/reconfirmation-info'
  29. import UserNotifications from '../../../../../frontend/js/features/project-list/components/notifications/user-notifications'
  30. import { ProjectListProvider } from '../../../../../frontend/js/features/project-list/context/project-list-context'
  31. import {
  32. Notification,
  33. Institution as InstitutionType,
  34. } from '../../../../../types/project/dashboard/notification'
  35. import { DeepPartial } from '../../../../../types/utils'
  36. import { Project } from '../../../../../types/project/dashboard/api'
  37. import GroupsAndEnterpriseBanner from '../../../../../frontend/js/features/project-list/components/notifications/groups-and-enterprise-banner'
  38. import GroupSsoSetupSuccess from '../../../../../frontend/js/features/project-list/components/notifications/groups/group-sso-setup-success'
  39. import localStorage from '../../../../../frontend/js/infrastructure/local-storage'
  40. import * as useLocationModule from '../../../../../frontend/js/shared/hooks/use-location'
  41. import {
  42. commonsSubscription,
  43. freeSubscription,
  44. groupSubscription,
  45. individualSubscription,
  46. } from '../fixtures/user-subscriptions'
  47. const renderWithinProjectListProvider = (Component: React.ComponentType) => {
  48. render(<Component />, {
  49. wrapper: ({ children }) => (
  50. <ProjectListProvider>
  51. <ul className="list-unstyled">{children}</ul>
  52. </ProjectListProvider>
  53. ),
  54. })
  55. }
  56. describe('<UserNotifications />', function () {
  57. const exposedSettings = {
  58. samlInitPath: '/fakeSaml/',
  59. appName: 'Overleaf',
  60. }
  61. beforeEach(function () {
  62. window.metaAttributesCache = window.metaAttributesCache || new Map()
  63. fetchMock.reset()
  64. // at least one project is required to show some notifications
  65. const projects = [{}] as Project[]
  66. fetchMock.post(/\/api\/project/, {
  67. status: 200,
  68. body: {
  69. projects,
  70. totalSize: projects.length,
  71. },
  72. })
  73. })
  74. afterEach(function () {
  75. window.metaAttributesCache = new Map()
  76. fetchMock.reset()
  77. })
  78. describe('<Common>', function () {
  79. beforeEach(function () {
  80. window.metaAttributesCache = window.metaAttributesCache || new Map()
  81. window.metaAttributesCache.set('ol-ExposedSettings', exposedSettings)
  82. })
  83. afterEach(function () {
  84. window.metaAttributesCache = new Map()
  85. fetchMock.reset()
  86. })
  87. it('accepts project invite', async function () {
  88. const reconfiguredNotification: DeepPartial<Notification> = {
  89. _id: 1,
  90. templateKey: 'notification_project_invite',
  91. }
  92. window.metaAttributesCache.set('ol-notifications', [
  93. merge(cloneDeep(notificationProjectInvite), reconfiguredNotification),
  94. ])
  95. renderWithinProjectListProvider(Common)
  96. await fetchMock.flush(true)
  97. const deleteMock = fetchMock.delete(
  98. `/notifications/${reconfiguredNotification._id}`,
  99. 200
  100. )
  101. const acceptMock = fetchMock.post(
  102. `project/${notificationProjectInvite.messageOpts.projectId}/invite/token/${notificationProjectInvite.messageOpts.token}/accept`,
  103. 200
  104. )
  105. screen.getByRole('alert')
  106. screen.getByText(/would like you to join/i)
  107. const joinBtn = screen.getByRole('button', {
  108. name: /join project/i,
  109. }) as HTMLButtonElement
  110. expect(joinBtn.disabled).to.be.false
  111. fireEvent.click(joinBtn)
  112. expect(joinBtn.disabled).to.be.true
  113. await waitForElementToBeRemoved(() =>
  114. screen.getByRole('button', { name: /joining/i })
  115. )
  116. expect(acceptMock.called()).to.be.true
  117. screen.getByText(/joined/i)
  118. expect(screen.queryByRole('button', { name: /join project/i })).to.be.null
  119. const openProject = screen.getByRole('link', { name: /open project/i })
  120. expect(openProject.getAttribute('href')).to.equal(
  121. `/project/${notificationProjectInvite.messageOpts.projectId}`
  122. )
  123. const closeBtn = screen.getByRole('button', { name: /close/i })
  124. fireEvent.click(closeBtn)
  125. expect(deleteMock.called()).to.be.true
  126. expect(screen.queryByRole('alert')).to.be.null
  127. })
  128. it('fails to accept project invite', async function () {
  129. const reconfiguredNotification: DeepPartial<Notification> = {
  130. _id: 1,
  131. templateKey: 'notification_project_invite',
  132. }
  133. window.metaAttributesCache.set('ol-notifications', [
  134. merge(cloneDeep(notificationProjectInvite), reconfiguredNotification),
  135. ])
  136. renderWithinProjectListProvider(Common)
  137. await fetchMock.flush(true)
  138. fetchMock.post(
  139. `project/${notificationProjectInvite.messageOpts.projectId}/invite/token/${notificationProjectInvite.messageOpts.token}/accept`,
  140. 500
  141. )
  142. screen.getByRole('alert')
  143. screen.getByText(/would like you to join/i)
  144. const joinBtn = screen.getByRole('button', {
  145. name: /join project/i,
  146. }) as HTMLButtonElement
  147. fireEvent.click(joinBtn)
  148. await waitForElementToBeRemoved(() =>
  149. screen.getByRole('button', { name: /joining/i })
  150. )
  151. expect(fetchMock.called()).to.be.true
  152. screen.getByRole('button', { name: /join project/i })
  153. expect(screen.queryByRole('link', { name: /open project/i })).to.be.null
  154. })
  155. it('shows WFH2020', async function () {
  156. const reconfiguredNotification: DeepPartial<Notification> = {
  157. _id: 1,
  158. templateKey: 'wfh_2020_upgrade_offer',
  159. }
  160. window.metaAttributesCache.set('ol-notifications', [
  161. merge(reconfiguredNotification),
  162. ])
  163. renderWithinProjectListProvider(Common)
  164. await fetchMock.flush(true)
  165. fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
  166. screen.getByRole('alert')
  167. screen.getByText(/your free WFH2020 upgrade came to an end on/i)
  168. const viewLink = screen.getByRole('link', { name: /view/i })
  169. expect(viewLink.getAttribute('href')).to.equal(
  170. 'https://www.overleaf.com/events/wfh2020'
  171. )
  172. const closeBtn = screen.getByRole('button', { name: /close/i })
  173. fireEvent.click(closeBtn)
  174. expect(fetchMock.called()).to.be.true
  175. expect(screen.queryByRole('alert')).to.be.null
  176. })
  177. it('shows IP matched affiliation with SSO enabled', async function () {
  178. const reconfiguredNotification: DeepPartial<Notification> = {
  179. _id: 1,
  180. templateKey: 'notification_ip_matched_affiliation',
  181. messageOpts: { ssoEnabled: true },
  182. }
  183. window.metaAttributesCache.set('ol-notifications', [
  184. merge(
  185. cloneDeep(notificationIPMatchedAffiliation),
  186. reconfiguredNotification
  187. ),
  188. ])
  189. renderWithinProjectListProvider(Common)
  190. await fetchMock.flush(true)
  191. fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
  192. screen.getByRole('alert')
  193. screen.getByText(/it looks like you’re at/i)
  194. screen.getByText(/you can now log in through your institution/i)
  195. screen.getByText(
  196. /link an institutional email address to your account to get started/i
  197. )
  198. const findOutMore = screen.getByRole('link', { name: /find out more/i })
  199. expect(findOutMore.getAttribute('href')).to.equal(
  200. 'https://www.overleaf.com/learn/how-to/Institutional_Login'
  201. )
  202. const linkAccount = screen.getByRole('link', { name: /link account/i })
  203. expect(linkAccount.getAttribute('href')).to.equal(
  204. `${exposedSettings.samlInitPath}?university_id=${notificationIPMatchedAffiliation.messageOpts.institutionId}&auto=/project`
  205. )
  206. const closeBtn = screen.getByRole('button', { name: /close/i })
  207. fireEvent.click(closeBtn)
  208. expect(fetchMock.called()).to.be.true
  209. expect(screen.queryByRole('alert')).to.be.null
  210. })
  211. it('shows IP matched affiliation with SSO disabled', async function () {
  212. const reconfiguredNotification: DeepPartial<Notification> = {
  213. _id: 1,
  214. templateKey: 'notification_ip_matched_affiliation',
  215. messageOpts: { ssoEnabled: false },
  216. }
  217. window.metaAttributesCache.set('ol-notifications', [
  218. merge(
  219. cloneDeep(notificationIPMatchedAffiliation),
  220. reconfiguredNotification
  221. ),
  222. ])
  223. renderWithinProjectListProvider(Common)
  224. await fetchMock.flush(true)
  225. fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
  226. screen.getByRole('alert')
  227. screen.getByText(/it looks like you’re at/i)
  228. screen.getByText(/did you know that/i)
  229. screen.getByText(
  230. /add an institutional email address to claim your features/i
  231. )
  232. const addAffiliation = screen.getByRole('link', {
  233. name: /add affiliation/i,
  234. })
  235. expect(addAffiliation.getAttribute('href')).to.equal(`/user/settings`)
  236. const closeBtn = screen.getByRole('button', { name: /close/i })
  237. fireEvent.click(closeBtn)
  238. expect(fetchMock.called()).to.be.true
  239. expect(screen.queryByRole('alert')).to.be.null
  240. })
  241. it('shows tpds file limit', async function () {
  242. const reconfiguredNotification: DeepPartial<Notification> = {
  243. templateKey: 'notification_tpds_file_limit',
  244. }
  245. window.metaAttributesCache.set('ol-notifications', [
  246. merge(cloneDeep(notificationTPDSFileLimit), reconfiguredNotification),
  247. ])
  248. renderWithinProjectListProvider(Common)
  249. await fetchMock.flush(true)
  250. screen.getByRole('alert')
  251. screen.getByText(/file limit/i)
  252. screen.getByText(
  253. /please decrease the size of your project to prevent further errors/i
  254. )
  255. const accountSettings = screen.getByRole('link', {
  256. name: /account settings/i,
  257. })
  258. expect(accountSettings.getAttribute('href')).to.equal('/user/settings')
  259. const closeBtn = screen.getByRole('button', { name: /close/i })
  260. fireEvent.click(closeBtn)
  261. expect(screen.queryByRole('alert')).to.be.null
  262. })
  263. it('shows dropbox duplicate project names warning', async function () {
  264. const reconfiguredNotification: DeepPartial<Notification> = {
  265. _id: 1,
  266. templateKey: 'notification_dropbox_duplicate_project_names',
  267. }
  268. window.metaAttributesCache.set('ol-notifications', [
  269. merge(
  270. cloneDeep(notificationDropboxDuplicateProjectNames),
  271. reconfiguredNotification
  272. ),
  273. ])
  274. renderWithinProjectListProvider(Common)
  275. await fetchMock.flush(true)
  276. fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
  277. screen.getByRole('alert')
  278. screen.getByText(/you have more than one project called/i)
  279. screen.getByText(/make your project names unique/i)
  280. const learnMore = screen.getByRole('link', { name: /learn more/i })
  281. expect(learnMore.getAttribute('href')).to.equal(
  282. '/learn/how-to/Dropbox_Synchronization#Troubleshooting'
  283. )
  284. const closeBtn = screen.getByRole('button', { name: /close/i })
  285. fireEvent.click(closeBtn)
  286. expect(fetchMock.called()).to.be.true
  287. expect(screen.queryByRole('alert')).to.be.null
  288. })
  289. it('shows dropbox unlinked tue to lapsed reconfirmation', async function () {
  290. const reconfiguredNotification: DeepPartial<Notification> = {
  291. _id: 1,
  292. templateKey:
  293. 'notification_dropbox_unlinked_due_to_lapsed_reconfirmation',
  294. }
  295. window.metaAttributesCache.set('ol-notifications', [
  296. merge(
  297. cloneDeep(notificationDropboxDuplicateProjectNames),
  298. reconfiguredNotification
  299. ),
  300. ])
  301. renderWithinProjectListProvider(Common)
  302. await fetchMock.flush(true)
  303. fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
  304. screen.getByRole('alert')
  305. screen.getByText(/your Dropbox account has been unlinked/i)
  306. screen.getByText(
  307. /confirm you are still at the institution and on their license, or upgrade your account in order to relink your dropbox account/i
  308. )
  309. const learnMore = screen.getByRole('link', { name: /learn more/i })
  310. expect(learnMore.getAttribute('href')).to.equal(
  311. '/learn/how-to/Institutional_Email_Reconfirmation'
  312. )
  313. const closeBtn = screen.getByRole('button', { name: /close/i })
  314. fireEvent.click(closeBtn)
  315. expect(fetchMock.called()).to.be.true
  316. expect(screen.queryByRole('alert')).to.be.null
  317. })
  318. it('shows non specific notification', async function () {
  319. const reconfiguredNotification: DeepPartial<Notification> = {
  320. _id: 1,
  321. html: 'unspecific message',
  322. }
  323. window.metaAttributesCache.set('ol-notifications', [
  324. reconfiguredNotification,
  325. ])
  326. renderWithinProjectListProvider(Common)
  327. await fetchMock.flush(true)
  328. fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
  329. screen.getByRole('alert')
  330. screen.getByText(reconfiguredNotification.html!)
  331. const closeBtn = screen.getByRole('button', { name: /close/i })
  332. fireEvent.click(closeBtn)
  333. expect(fetchMock.called()).to.be.true
  334. expect(screen.queryByRole('alert')).to.be.null
  335. })
  336. describe('<GroupInvitation />', function () {
  337. describe('without existing personal subscription', function () {
  338. it('shows group invitation notification for user without personal subscription', async function () {
  339. const notificationGroupInvite: DeepPartial<Notification> = {
  340. _id: 1,
  341. templateKey: 'notification_group_invitation',
  342. }
  343. window.metaAttributesCache.set('ol-notifications', [
  344. merge(
  345. cloneDeep(notificationGroupInviteDefault),
  346. notificationGroupInvite
  347. ),
  348. ])
  349. renderWithinProjectListProvider(Common)
  350. await fetchMock.flush(true)
  351. fetchMock.delete(`/notifications/${notificationGroupInvite._id}`, 200)
  352. screen.getByRole('alert')
  353. screen.getByText('inviter@overleaf.com')
  354. screen.getByText(
  355. /has invited you to join a group subscription on Overleaf/
  356. )
  357. screen.getByRole('button', { name: 'Join now' })
  358. screen.getByRole('button', { name: /close/i })
  359. })
  360. describe('with existing personal subscription', function () {
  361. it('shows group invitation notification for user with personal subscription', async function () {
  362. const notificationGroupInvite: DeepPartial<Notification> = {
  363. _id: 1,
  364. templateKey: 'notification_group_invitation',
  365. }
  366. window.metaAttributesCache.set('ol-notifications', [
  367. merge(
  368. cloneDeep(notificationGroupInviteDefault),
  369. notificationGroupInvite
  370. ),
  371. ])
  372. window.metaAttributesCache.set(
  373. 'ol-hasIndividualRecurlySubscription',
  374. true
  375. )
  376. renderWithinProjectListProvider(Common)
  377. await fetchMock.flush(true)
  378. fetchMock.delete(
  379. `/notifications/${notificationGroupInvite._id}`,
  380. 200
  381. )
  382. screen.getByRole('alert')
  383. screen.getByText(
  384. /inviter@overleaf.com has invited you to join a group Overleaf subscription. If you join this group, you may not need your individual subscription. Would you like to cancel it/
  385. )
  386. screen.getByRole('button', { name: 'Not now' })
  387. screen.getByRole('button', { name: 'Cancel my subscription' })
  388. })
  389. })
  390. })
  391. })
  392. })
  393. describe('<Institution>', function () {
  394. beforeEach(function () {
  395. window.metaAttributesCache = window.metaAttributesCache || new Map()
  396. window.metaAttributesCache.set('ol-ExposedSettings', exposedSettings)
  397. fetchMock.reset()
  398. })
  399. afterEach(function () {
  400. fetchMock.reset()
  401. })
  402. it('shows sso available', function () {
  403. const institution: DeepPartial<InstitutionType> = {
  404. templateKey: 'notification_institution_sso_available',
  405. }
  406. window.metaAttributesCache.set('ol-notificationsInstitution', [
  407. { ...notificationsInstitution, ...institution },
  408. ])
  409. render(<Institution />)
  410. screen.getByRole('alert')
  411. screen.getByText(/you can now link/i)
  412. screen.getByText(/doing this will allow you to log in/i)
  413. const learnMore = screen.getByRole('link', { name: /learn more/i })
  414. expect(learnMore.getAttribute('href')).to.equal(
  415. '/learn/how-to/Institutional_Login'
  416. )
  417. const action = screen.getByRole('link', { name: /link account/i })
  418. expect(action.getAttribute('href')).to.equal(
  419. `${exposedSettings.samlInitPath}?university_id=${notificationsInstitution.institutionId}&auto=/project&email=${notificationsInstitution.email}`
  420. )
  421. })
  422. it('shows sso linked', function () {
  423. const institution: DeepPartial<InstitutionType> = {
  424. _id: 1,
  425. templateKey: 'notification_institution_sso_linked',
  426. }
  427. window.metaAttributesCache.set('ol-notificationsInstitution', [
  428. { ...notificationsInstitution, ...institution },
  429. ])
  430. render(<Institution />)
  431. fetchMock.delete(`/notifications/${institution._id}`, 200)
  432. screen.getByRole('alert')
  433. screen.getByText(/has been linked to your/i)
  434. const closeBtn = screen.getByRole('button', { name: /close/i })
  435. fireEvent.click(closeBtn)
  436. expect(fetchMock.called()).to.be.true
  437. expect(screen.queryByRole('alert')).to.be.null
  438. })
  439. it('shows sso non canonical', function () {
  440. const institution: DeepPartial<InstitutionType> = {
  441. _id: 1,
  442. templateKey: 'notification_institution_sso_non_canonical',
  443. }
  444. window.metaAttributesCache.set('ol-notificationsInstitution', [
  445. { ...notificationsInstitution, ...institution },
  446. ])
  447. render(<Institution />)
  448. fetchMock.delete(`/notifications/${institution._id}`, 200)
  449. screen.getByRole('alert')
  450. screen.getByText(/you’ve tried to log in with/i)
  451. screen.getByText(
  452. /in order to match your institutional metadata, your account is associated with/i
  453. )
  454. const closeBtn = screen.getByRole('button', { name: /close/i })
  455. fireEvent.click(closeBtn)
  456. expect(fetchMock.called()).to.be.true
  457. expect(screen.queryByRole('alert')).to.be.null
  458. })
  459. it('shows sso already registered', function () {
  460. const institution: DeepPartial<InstitutionType> = {
  461. _id: 1,
  462. templateKey: 'notification_institution_sso_already_registered',
  463. }
  464. window.metaAttributesCache.set('ol-notificationsInstitution', [
  465. { ...notificationsInstitution, ...institution },
  466. ])
  467. render(<Institution />)
  468. fetchMock.delete(`/notifications/${institution._id}`, 200)
  469. screen.getByRole('alert')
  470. screen.getByText(/which is already registered with/i)
  471. const action = screen.getByRole('link', { name: /find out more/i })
  472. expect(action.getAttribute('href')).to.equal(
  473. '/learn/how-to/Institutional_Login'
  474. )
  475. const closeBtn = screen.getByRole('button', { name: /close/i })
  476. fireEvent.click(closeBtn)
  477. expect(fetchMock.called()).to.be.true
  478. expect(screen.queryByRole('alert')).to.be.null
  479. })
  480. it('shows sso error', function () {
  481. const institution: DeepPartial<InstitutionType> = {
  482. templateKey: 'notification_institution_sso_error',
  483. error: {
  484. message: 'fake message',
  485. tryAgain: true,
  486. },
  487. }
  488. window.metaAttributesCache.set('ol-notificationsInstitution', [
  489. { ...notificationsInstitution, ...institution },
  490. ])
  491. render(<Institution />)
  492. screen.getByRole('alert')
  493. screen.getByText(/something went wrong/i)
  494. screen.getByText(institution.error!.message!)
  495. screen.getByText(/please try again/i)
  496. const closeBtn = screen.getByRole('button', { name: /close/i })
  497. fireEvent.click(closeBtn)
  498. expect(screen.queryByRole('alert')).to.be.null
  499. })
  500. })
  501. describe('<ConfirmEmail/>', function () {
  502. beforeEach(async function () {
  503. window.metaAttributesCache.set('ol-ExposedSettings', {
  504. emailConfirmationDisabled: false,
  505. })
  506. window.metaAttributesCache.set('ol-userEmails', [unconfirmedUserData])
  507. window.metaAttributesCache.set(
  508. 'ol-usersBestSubscription',
  509. freeSubscription
  510. )
  511. })
  512. afterEach(function () {
  513. fetchMock.reset()
  514. })
  515. it('sends successfully', async function () {
  516. renderWithinProjectListProvider(ConfirmEmail)
  517. await fetchMock.flush(true)
  518. fetchMock.post('/user/emails/resend_confirmation', 200)
  519. const email = unconfirmedUserData.email
  520. const notificationBody = screen.getByTestId('pro-notification-body')
  521. expect(notificationBody.textContent).to.contain(
  522. `Please confirm your email ${email} by clicking on the link in the confirmation email`
  523. )
  524. const resendButton = screen.getByRole('button', { name: /resend/i })
  525. fireEvent.click(resendButton)
  526. expect(screen.queryByRole('button', { name: /resend/i })).to.be.null
  527. await waitForElementToBeRemoved(() =>
  528. screen.getByText(/resending confirmation email/i)
  529. )
  530. expect(fetchMock.called()).to.be.true
  531. expect(screen.queryByRole('alert')).to.be.null
  532. })
  533. it('fails to send', async function () {
  534. renderWithinProjectListProvider(ConfirmEmail)
  535. await fetchMock.flush(true)
  536. fetchMock.post('/user/emails/resend_confirmation', 500)
  537. const resendButton = screen.getByRole('button', { name: /resend/i })
  538. fireEvent.click(resendButton)
  539. await waitForElementToBeRemoved(() =>
  540. screen.getByText(/resending confirmation email/i)
  541. )
  542. expect(fetchMock.called()).to.be.true
  543. screen.getByText(/something went wrong/i)
  544. })
  545. for (const subscription of [freeSubscription, individualSubscription]) {
  546. it(`shows commons notification for commons account when user is on ${subscription.type} plan`, async function () {
  547. window.metaAttributesCache.set('ol-userEmails', [
  548. unconfirmedCommonsUserData,
  549. ])
  550. window.metaAttributesCache.set('ol-usersBestSubscription', subscription)
  551. renderWithinProjectListProvider(ConfirmEmail)
  552. await fetchMock.flush(true)
  553. const alert = screen.getByRole('alert')
  554. const email = unconfirmedCommonsUserData.email
  555. const notificationBody = within(alert).getByTestId('notification-body')
  556. expect(notificationBody.textContent).to.contain(
  557. 'You are one step away from accessing Overleaf Professional features'
  558. )
  559. expect(notificationBody.textContent).to.contain(
  560. `Overleaf has an Overleaf subscription. Click the confirmation link sent to ${email} to upgrade to Overleaf Professional`
  561. )
  562. })
  563. }
  564. for (const subscription of [groupSubscription, commonsSubscription]) {
  565. it(`shows default notification for commons account when user is on ${subscription.type} plan`, async function () {
  566. window.metaAttributesCache.set('ol-userEmails', [
  567. unconfirmedCommonsUserData,
  568. ])
  569. window.metaAttributesCache.set('ol-usersBestSubscription', subscription)
  570. renderWithinProjectListProvider(ConfirmEmail)
  571. await fetchMock.flush(true)
  572. const alert = screen.getByRole('alert')
  573. const email = unconfirmedCommonsUserData.email
  574. const notificationBody = within(alert).getByTestId(
  575. 'pro-notification-body'
  576. )
  577. expect(notificationBody.textContent).to.contain(
  578. `Please confirm your email ${email} by clicking on the link in the confirmation email`
  579. )
  580. })
  581. }
  582. })
  583. describe('<Affiliation/>', function () {
  584. let assignStub: sinon.SinonStub
  585. beforeEach(function () {
  586. window.metaAttributesCache = window.metaAttributesCache || new Map()
  587. window.metaAttributesCache.set('ol-ExposedSettings', exposedSettings)
  588. assignStub = sinon.stub()
  589. this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
  590. assign: assignStub,
  591. replace: sinon.stub(),
  592. reload: sinon.stub(),
  593. })
  594. fetchMock.reset()
  595. })
  596. afterEach(function () {
  597. window.metaAttributesCache = new Map()
  598. this.locationStub.restore()
  599. fetchMock.reset()
  600. })
  601. it('shows reconfirm message with SSO disabled', async function () {
  602. window.metaAttributesCache.set('ol-allInReconfirmNotificationPeriods', [
  603. { ...professionalUserData, samlProviderId: 'Saml Provider' },
  604. ])
  605. render(<ReconfirmationInfo />)
  606. screen.getByRole('alert')
  607. screen.getByText(
  608. /take a moment to confirm your institutional email address/i
  609. )
  610. const removeLink = screen.getByRole('link', { name: /remove it/i })
  611. expect(removeLink.getAttribute('href')).to.equal(
  612. `/user/settings?remove=${professionalUserData.email}`
  613. )
  614. const learnMore = screen.getByRole('link', { name: /learn more/i })
  615. expect(learnMore.getAttribute('href')).to.equal(
  616. '/learn/how-to/Institutional_Email_Reconfirmation'
  617. )
  618. const sendReconfirmationMock = fetchMock.post(
  619. '/user/emails/send-reconfirmation',
  620. 200
  621. )
  622. fireEvent.click(
  623. screen.getByRole('button', { name: /confirm affiliation/i })
  624. )
  625. await waitForElementToBeRemoved(() => screen.getByText(/sending/i))
  626. screen.getByText(/check your email inbox to confirm/i)
  627. expect(screen.queryByRole('button', { name: /confirm affiliation/i })).to
  628. .be.null
  629. expect(screen.queryByRole('link', { name: /remove it/i })).to.be.null
  630. expect(screen.queryByRole('link', { name: /learn more/i })).to.be.null
  631. expect(sendReconfirmationMock.called()).to.be.true
  632. fireEvent.click(
  633. screen.getByRole('button', { name: /resend confirmation email/i })
  634. )
  635. await waitForElementToBeRemoved(() => screen.getByText(/sending/i))
  636. expect(sendReconfirmationMock.calls()).to.have.lengthOf(2)
  637. })
  638. it('shows reconfirm message with SSO enabled', async function () {
  639. window.metaAttributesCache.set('ol-allInReconfirmNotificationPeriods', [
  640. merge(cloneDeep(professionalUserData), {
  641. affiliation: { institution: { ssoEnabled: true } },
  642. }),
  643. ])
  644. render(<ReconfirmationInfo />)
  645. fireEvent.click(
  646. screen.getByRole('button', { name: /confirm affiliation/i })
  647. )
  648. sinon.assert.calledOnce(assignStub)
  649. sinon.assert.calledWithMatch(
  650. assignStub,
  651. `${exposedSettings.samlInitPath}?university_id=${professionalUserData.affiliation.institution.id}&reconfirm=/project`
  652. )
  653. })
  654. it('shows success reconfirmation message', function () {
  655. window.metaAttributesCache.set('ol-userEmails', [
  656. { ...professionalUserData, samlProviderId: 'Saml Provider' },
  657. ])
  658. window.metaAttributesCache.set('ol-reconfirmedViaSAML', 'Saml Provider')
  659. const { rerender } = render(<ReconfirmationInfo />)
  660. screen.getByRole('alert')
  661. screen.getByText(professionalUserData.affiliation!.institution!.name!)
  662. screen.getByText(/affiliation is confirmed/i)
  663. window.metaAttributesCache.set('ol-reconfirmedViaSAML', '')
  664. rerender(<ReconfirmationInfo />)
  665. expect(screen.queryByRole('alert')).to.be.null
  666. })
  667. })
  668. describe('<GroupsAndEnterpriseBanner />', function () {
  669. beforeEach(function () {
  670. window.metaAttributesCache = window.metaAttributesCache || new Map()
  671. localStorage.clear()
  672. fetchMock.reset()
  673. // at least one project is required to show some notifications
  674. const projects = [{}] as Project[]
  675. fetchMock.post(/\/api\/project/, {
  676. status: 200,
  677. body: {
  678. projects,
  679. totalSize: projects.length,
  680. },
  681. })
  682. window.metaAttributesCache.set(
  683. 'ol-groupsAndEnterpriseBannerVariant',
  684. 'did-you-know'
  685. )
  686. })
  687. afterEach(function () {
  688. fetchMock.reset()
  689. window.metaAttributesCache = window.metaAttributesCache || new Map()
  690. })
  691. it('does not show the banner for users that are in group or are affiliated', async function () {
  692. window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', false)
  693. renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
  694. await fetchMock.flush(true)
  695. expect(screen.queryByRole('link', { name: 'Contact Sales' })).to.be.null
  696. })
  697. it('shows the banner for users that have dismissed the previous banners', async function () {
  698. window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', true)
  699. localStorage.setItem('has_dismissed_groups_and_enterprise_banner', true)
  700. renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
  701. await fetchMock.flush(true)
  702. expect(screen.queryByRole('link', { name: 'Contact Sales' })).to.not.be
  703. .null
  704. })
  705. it('shows the banner for users that have dismissed the banner more than 30 days ago', async function () {
  706. const dismissed = new Date()
  707. dismissed.setDate(dismissed.getDate() - 31) // 31 days
  708. window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', true)
  709. localStorage.setItem(
  710. 'has_dismissed_groups_and_enterprise_banner',
  711. dismissed
  712. )
  713. renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
  714. await fetchMock.flush(true)
  715. expect(screen.queryByRole('link', { name: 'Contact Sales' })).to.not.be
  716. .null
  717. })
  718. it('does not show the banner for users that have dismissed the banner within the last 30 days', async function () {
  719. const dismissed = new Date()
  720. dismissed.setDate(dismissed.getDate() - 29) // 29 days
  721. window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', true)
  722. localStorage.setItem(
  723. 'has_dismissed_groups_and_enterprise_banner',
  724. dismissed
  725. )
  726. renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
  727. await fetchMock.flush(true)
  728. expect(screen.queryByRole('link', { name: 'Contact Sales' })).to.be.null
  729. })
  730. describe('users that are not in group and are not affiliated', function () {
  731. beforeEach(function () {
  732. localStorage.clear()
  733. fetchMock.reset()
  734. // at least one project is required to show some notifications
  735. const projects = [{}] as Project[]
  736. fetchMock.post(/\/api\/project/, {
  737. status: 200,
  738. body: {
  739. projects,
  740. totalSize: projects.length,
  741. },
  742. })
  743. window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', true)
  744. })
  745. afterEach(function () {
  746. fetchMock.reset()
  747. window.metaAttributesCache = window.metaAttributesCache || new Map()
  748. })
  749. after(function () {
  750. localStorage.clear()
  751. })
  752. it('will show the correct text for the `did-you-know` variant', async function () {
  753. window.metaAttributesCache.set(
  754. 'ol-groupsAndEnterpriseBannerVariant',
  755. 'did-you-know'
  756. )
  757. renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
  758. await fetchMock.flush(true)
  759. screen.getByText(
  760. 'Did you know that Overleaf offers group and organization-wide subscription options? Request information or a quote.'
  761. )
  762. const link = screen.getByRole('link', { name: 'Contact Sales' })
  763. expect(link.getAttribute('href')).to.equal(`/for/contact-sales-1`)
  764. })
  765. it('will show the correct text for the `on-premise` variant', async function () {
  766. window.metaAttributesCache.set(
  767. 'ol-groupsAndEnterpriseBannerVariant',
  768. 'on-premise'
  769. )
  770. renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
  771. await fetchMock.flush(true)
  772. screen.getByText(
  773. '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.'
  774. )
  775. const link = screen.getByRole('link', { name: 'Contact Sales' })
  776. expect(link.getAttribute('href')).to.equal(`/for/contact-sales-2`)
  777. })
  778. it('will show the correct text for the `people` variant', async function () {
  779. window.metaAttributesCache.set(
  780. 'ol-groupsAndEnterpriseBannerVariant',
  781. 'people'
  782. )
  783. renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
  784. await fetchMock.flush(true)
  785. screen.getByText(
  786. 'Other people at your company may already be using Overleaf. Save money with Overleaf group and company-wide subscriptions. Request more information.'
  787. )
  788. const link = screen.getByRole('link', { name: 'Contact Sales' })
  789. expect(link.getAttribute('href')).to.equal(`/for/contact-sales-3`)
  790. })
  791. it('will show the correct text for the `FOMO` variant', async function () {
  792. window.metaAttributesCache.set(
  793. 'ol-groupsAndEnterpriseBannerVariant',
  794. 'FOMO'
  795. )
  796. renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
  797. await fetchMock.flush(true)
  798. screen.getByText(
  799. 'Why do Fortune 500 companies and top research institutions trust Overleaf to streamline their collaboration? Get in touch to learn more.'
  800. )
  801. const link = screen.getByRole('link', { name: 'Contact Sales' })
  802. expect(link.getAttribute('href')).to.equal(`/for/contact-sales-4`)
  803. })
  804. })
  805. })
  806. describe('<WritefullPromoBanner>', function () {
  807. beforeEach(function () {
  808. window.metaAttributesCache = window.metaAttributesCache || new Map()
  809. window.metaAttributesCache.set('ol-ExposedSettings', exposedSettings)
  810. window.metaAttributesCache.set('ol-showWritefullPromoBanner', true)
  811. // The older banner is only shown to Chrome users
  812. const navigator = window.navigator as any
  813. navigator.userAgentData = { brands: [{ brand: 'Chromium' }] }
  814. localStorage.clear()
  815. })
  816. afterEach(function () {
  817. window.metaAttributesCache = window.metaAttributesCache || new Map()
  818. })
  819. describe('when the writefull integration is enabled', function () {
  820. beforeEach(function () {
  821. window.metaAttributesCache.set('ol-user', {
  822. writefull: { enabled: true },
  823. })
  824. })
  825. it('shows the banner', function () {
  826. renderWithinProjectListProvider(UserNotifications)
  827. const ctaLink = screen.getByRole('link', {
  828. name: 'Get Writefull Premium',
  829. })
  830. expect(ctaLink.getAttribute('href')).to.equal(
  831. 'https://my.writefull.com/overleaf-invite?code=OVERLEAF10&redirect=plans'
  832. )
  833. })
  834. it('dismisses the banner when the close button is clicked', function () {
  835. renderWithinProjectListProvider(UserNotifications)
  836. screen.getByRole('link', { name: /Writefull/ })
  837. const WritefullPromoBanner = screen.getByTestId(
  838. 'writefull-premium-promo-banner'
  839. )
  840. const closeButton = within(WritefullPromoBanner).getByRole('button', {
  841. name: 'Close',
  842. })
  843. fireEvent.click(closeButton)
  844. expect(screen.queryByRole('link', { name: /Writefull/ })).to.be.null
  845. expect(localStorage.getItem('has_dismissed_writefull_promo_banner')).to
  846. .exist
  847. })
  848. it("doesn't show the banner if it has been dismissed", function () {
  849. localStorage.setItem(
  850. 'has_dismissed_writefull_promo_banner',
  851. new Date(Date.now() - 500)
  852. )
  853. renderWithinProjectListProvider(UserNotifications)
  854. expect(screen.queryByRole('link', { name: /Writefull/ })).to.be.null
  855. })
  856. })
  857. describe('when the writefull integration is not enabled', function () {
  858. beforeEach(function () {
  859. window.metaAttributesCache.set('ol-user', {
  860. writefull: { enabled: false },
  861. })
  862. })
  863. it("doesn't show the banner", function () {
  864. renderWithinProjectListProvider(UserNotifications)
  865. expect(screen.queryByRole('link', { name: /Writefull/ })).to.be.null
  866. })
  867. })
  868. })
  869. describe('GroupSsoSetupSuccess', function () {
  870. beforeEach(function () {
  871. window.metaAttributesCache = window.metaAttributesCache || new Map()
  872. })
  873. afterEach(function () {
  874. window.metaAttributesCache = window.metaAttributesCache || new Map()
  875. })
  876. it('shows group SSO linked notification', function () {
  877. window.metaAttributesCache.set('ol-groupSsoSetupSuccess', true)
  878. renderWithinProjectListProvider(GroupSsoSetupSuccess)
  879. screen.getByText('Success! Single sign-on is all set up for you.')
  880. })
  881. it('does not show group SSO linked notification', function () {
  882. window.metaAttributesCache.set('ol-groupSsoSetupSuccess', false)
  883. renderWithinProjectListProvider(GroupSsoSetupSuccess)
  884. expect(
  885. screen.queryByText('Success! Single sign-on is all set up for you.')
  886. ).to.be.null
  887. })
  888. })
  889. })