notifications.test.tsx 35 KB

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