notifications.test.tsx 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101
  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(
  354. /inviter@overleaf.com 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. window.metaAttributesCache = window.metaAttributesCache || new Map()
  395. window.metaAttributesCache.set('ol-ExposedSettings', exposedSettings)
  396. fetchMock.reset()
  397. })
  398. afterEach(function () {
  399. fetchMock.reset()
  400. })
  401. it('shows sso available', function () {
  402. const institution: DeepPartial<InstitutionType> = {
  403. templateKey: 'notification_institution_sso_available',
  404. }
  405. window.metaAttributesCache.set('ol-notificationsInstitution', [
  406. { ...notificationsInstitution, ...institution },
  407. ])
  408. render(<Institution />)
  409. screen.getByRole('alert')
  410. screen.getByText(/you can now link/i)
  411. screen.getByText(/doing this will allow you to log in/i)
  412. const learnMore = screen.getByRole('link', { name: /learn more/i })
  413. expect(learnMore.getAttribute('href')).to.equal(
  414. '/learn/how-to/Institutional_Login'
  415. )
  416. const action = screen.getByRole('link', { name: /link account/i })
  417. expect(action.getAttribute('href')).to.equal(
  418. `${exposedSettings.samlInitPath}?university_id=${notificationsInstitution.institutionId}&auto=/project&email=${notificationsInstitution.email}`
  419. )
  420. })
  421. it('shows sso linked', function () {
  422. const institution: DeepPartial<InstitutionType> = {
  423. _id: 1,
  424. templateKey: 'notification_institution_sso_linked',
  425. }
  426. window.metaAttributesCache.set('ol-notificationsInstitution', [
  427. { ...notificationsInstitution, ...institution },
  428. ])
  429. render(<Institution />)
  430. fetchMock.delete(`/notifications/${institution._id}`, 200)
  431. screen.getByRole('alert')
  432. screen.getByText(/has been linked to your/i)
  433. const closeBtn = screen.getByRole('button', { name: /close/i })
  434. fireEvent.click(closeBtn)
  435. expect(fetchMock.called()).to.be.true
  436. expect(screen.queryByRole('alert')).to.be.null
  437. })
  438. it('shows sso non canonical', function () {
  439. const institution: DeepPartial<InstitutionType> = {
  440. _id: 1,
  441. templateKey: 'notification_institution_sso_non_canonical',
  442. }
  443. window.metaAttributesCache.set('ol-notificationsInstitution', [
  444. { ...notificationsInstitution, ...institution },
  445. ])
  446. render(<Institution />)
  447. fetchMock.delete(`/notifications/${institution._id}`, 200)
  448. screen.getByRole('alert')
  449. screen.getByText(/you’ve tried to log in with/i)
  450. screen.getByText(
  451. /in order to match your institutional metadata, your account is associated with/i
  452. )
  453. const closeBtn = screen.getByRole('button', { name: /close/i })
  454. fireEvent.click(closeBtn)
  455. expect(fetchMock.called()).to.be.true
  456. expect(screen.queryByRole('alert')).to.be.null
  457. })
  458. it('shows sso already registered', function () {
  459. const institution: DeepPartial<InstitutionType> = {
  460. _id: 1,
  461. templateKey: 'notification_institution_sso_already_registered',
  462. }
  463. window.metaAttributesCache.set('ol-notificationsInstitution', [
  464. { ...notificationsInstitution, ...institution },
  465. ])
  466. render(<Institution />)
  467. fetchMock.delete(`/notifications/${institution._id}`, 200)
  468. screen.getByRole('alert')
  469. screen.getByText(/which is already registered with/i)
  470. const action = screen.getByRole('link', { name: /find out more/i })
  471. expect(action.getAttribute('href')).to.equal(
  472. '/learn/how-to/Institutional_Login'
  473. )
  474. const closeBtn = screen.getByRole('button', { name: /close/i })
  475. fireEvent.click(closeBtn)
  476. expect(fetchMock.called()).to.be.true
  477. expect(screen.queryByRole('alert')).to.be.null
  478. })
  479. it('shows sso error', function () {
  480. const institution: DeepPartial<InstitutionType> = {
  481. templateKey: 'notification_institution_sso_error',
  482. error: {
  483. message: 'fake message',
  484. tryAgain: true,
  485. },
  486. }
  487. window.metaAttributesCache.set('ol-notificationsInstitution', [
  488. { ...notificationsInstitution, ...institution },
  489. ])
  490. render(<Institution />)
  491. screen.getByRole('alert')
  492. screen.getByText(/something went wrong/i)
  493. screen.getByText(institution.error!.message!)
  494. screen.getByText(/please try again/i)
  495. const closeBtn = screen.getByRole('button', { name: /close/i })
  496. fireEvent.click(closeBtn)
  497. expect(screen.queryByRole('alert')).to.be.null
  498. })
  499. })
  500. describe('<ConfirmEmail/>', function () {
  501. beforeEach(async function () {
  502. window.metaAttributesCache.set('ol-ExposedSettings', {
  503. emailConfirmationDisabled: false,
  504. })
  505. window.metaAttributesCache.set('ol-userEmails', [unconfirmedUserData])
  506. window.metaAttributesCache.set(
  507. 'ol-usersBestSubscription',
  508. freeSubscription
  509. )
  510. })
  511. afterEach(function () {
  512. fetchMock.reset()
  513. })
  514. it('sends successfully', async function () {
  515. renderWithinProjectListProvider(ConfirmEmail)
  516. await fetchMock.flush(true)
  517. fetchMock.post('/user/emails/resend_confirmation', 200)
  518. const email = unconfirmedUserData.email
  519. const notificationBody = screen.getByTestId('pro-notification-body')
  520. expect(notificationBody.textContent).to.contain(
  521. `Please confirm your email ${email} by clicking on the link in the confirmation email`
  522. )
  523. const resendButton = screen.getByRole('button', { name: /resend/i })
  524. fireEvent.click(resendButton)
  525. expect(screen.queryByRole('button', { name: /resend/i })).to.be.null
  526. await waitForElementToBeRemoved(() =>
  527. screen.getByText(/resending confirmation email/i)
  528. )
  529. expect(fetchMock.called()).to.be.true
  530. expect(screen.queryByRole('alert')).to.be.null
  531. })
  532. it('fails to send', async function () {
  533. renderWithinProjectListProvider(ConfirmEmail)
  534. await fetchMock.flush(true)
  535. fetchMock.post('/user/emails/resend_confirmation', 500)
  536. const resendButton = screen.getByRole('button', { name: /resend/i })
  537. fireEvent.click(resendButton)
  538. await waitForElementToBeRemoved(() =>
  539. screen.getByText(/resending confirmation email/i)
  540. )
  541. expect(fetchMock.called()).to.be.true
  542. screen.getByText(/something went wrong/i)
  543. })
  544. for (const subscription of [freeSubscription, individualSubscription]) {
  545. it(`shows commons notification for commons account when user is on ${subscription.type} plan`, async function () {
  546. window.metaAttributesCache.set('ol-userEmails', [
  547. unconfirmedCommonsUserData,
  548. ])
  549. window.metaAttributesCache.set('ol-usersBestSubscription', subscription)
  550. renderWithinProjectListProvider(ConfirmEmail)
  551. await fetchMock.flush(true)
  552. const alert = screen.getByRole('alert')
  553. const email = unconfirmedCommonsUserData.email
  554. const notificationBody = within(alert).getByTestId('notification-body')
  555. expect(notificationBody.textContent).to.contain(
  556. 'You are one step away from accessing Overleaf Professional features'
  557. )
  558. expect(notificationBody.textContent).to.contain(
  559. `Overleaf has an Overleaf subscription. Click the confirmation link sent to ${email} to upgrade to Overleaf Professional`
  560. )
  561. })
  562. }
  563. for (const subscription of [groupSubscription, commonsSubscription]) {
  564. it(`shows default notification for commons account when user is on ${subscription.type} plan`, async function () {
  565. window.metaAttributesCache.set('ol-userEmails', [
  566. unconfirmedCommonsUserData,
  567. ])
  568. window.metaAttributesCache.set('ol-usersBestSubscription', subscription)
  569. renderWithinProjectListProvider(ConfirmEmail)
  570. await fetchMock.flush(true)
  571. const alert = screen.getByRole('alert')
  572. const email = unconfirmedCommonsUserData.email
  573. const notificationBody = within(alert).getByTestId(
  574. 'pro-notification-body'
  575. )
  576. expect(notificationBody.textContent).to.contain(
  577. `Please confirm your email ${email} by clicking on the link in the confirmation email`
  578. )
  579. })
  580. }
  581. })
  582. describe('<Affiliation/>', function () {
  583. let assignStub: sinon.SinonStub
  584. beforeEach(function () {
  585. window.metaAttributesCache = window.metaAttributesCache || new Map()
  586. window.metaAttributesCache.set('ol-ExposedSettings', exposedSettings)
  587. assignStub = sinon.stub()
  588. this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
  589. assign: assignStub,
  590. reload: sinon.stub(),
  591. })
  592. fetchMock.reset()
  593. })
  594. afterEach(function () {
  595. window.metaAttributesCache = new Map()
  596. this.locationStub.restore()
  597. fetchMock.reset()
  598. })
  599. it('shows reconfirm message with SSO disabled', async function () {
  600. window.metaAttributesCache.set('ol-allInReconfirmNotificationPeriods', [
  601. { ...professionalUserData, samlProviderId: 'Saml Provider' },
  602. ])
  603. render(<ReconfirmationInfo />)
  604. screen.getByRole('alert')
  605. screen.getByText(
  606. /take a moment to confirm your institutional email address/i
  607. )
  608. const removeLink = screen.getByRole('link', { name: /remove it/i })
  609. expect(removeLink.getAttribute('href')).to.equal(
  610. `/user/settings?remove=${professionalUserData.email}`
  611. )
  612. const learnMore = screen.getByRole('link', { name: /learn more/i })
  613. expect(learnMore.getAttribute('href')).to.equal(
  614. '/learn/how-to/Institutional_Email_Reconfirmation'
  615. )
  616. const sendReconfirmationMock = fetchMock.post(
  617. '/user/emails/send-reconfirmation',
  618. 200
  619. )
  620. fireEvent.click(
  621. screen.getByRole('button', { name: /confirm affiliation/i })
  622. )
  623. await waitForElementToBeRemoved(() => screen.getByText(/sending/i))
  624. screen.getByText(/check your email inbox to confirm/i)
  625. expect(screen.queryByRole('button', { name: /confirm affiliation/i })).to
  626. .be.null
  627. expect(screen.queryByRole('link', { name: /remove it/i })).to.be.null
  628. expect(screen.queryByRole('link', { name: /learn more/i })).to.be.null
  629. expect(sendReconfirmationMock.called()).to.be.true
  630. fireEvent.click(
  631. screen.getByRole('button', { name: /resend confirmation email/i })
  632. )
  633. await waitForElementToBeRemoved(() => screen.getByText(/sending/i))
  634. expect(sendReconfirmationMock.calls()).to.have.lengthOf(2)
  635. })
  636. it('shows reconfirm message with SSO enabled', async function () {
  637. window.metaAttributesCache.set('ol-allInReconfirmNotificationPeriods', [
  638. merge(cloneDeep(professionalUserData), {
  639. affiliation: { institution: { ssoEnabled: true } },
  640. }),
  641. ])
  642. render(<ReconfirmationInfo />)
  643. fireEvent.click(
  644. screen.getByRole('button', { name: /confirm affiliation/i })
  645. )
  646. sinon.assert.calledOnce(assignStub)
  647. sinon.assert.calledWithMatch(
  648. assignStub,
  649. `${exposedSettings.samlInitPath}?university_id=${professionalUserData.affiliation.institution.id}&reconfirm=/project`
  650. )
  651. })
  652. it('shows success reconfirmation message', function () {
  653. window.metaAttributesCache.set('ol-userEmails', [
  654. { ...professionalUserData, samlProviderId: 'Saml Provider' },
  655. ])
  656. window.metaAttributesCache.set('ol-reconfirmedViaSAML', 'Saml Provider')
  657. const { rerender } = render(<ReconfirmationInfo />)
  658. screen.getByRole('alert')
  659. screen.getByText(professionalUserData.affiliation!.institution!.name!)
  660. screen.getByText(/affiliation is confirmed/i)
  661. window.metaAttributesCache.set('ol-reconfirmedViaSAML', '')
  662. rerender(<ReconfirmationInfo />)
  663. expect(screen.queryByRole('alert')).to.be.null
  664. })
  665. })
  666. describe('<GroupsAndEnterpriseBanner />', function () {
  667. beforeEach(function () {
  668. window.metaAttributesCache = window.metaAttributesCache || new Map()
  669. localStorage.clear()
  670. fetchMock.reset()
  671. // at least one project is required to show some notifications
  672. const projects = [{}] as Project[]
  673. fetchMock.post(/\/api\/project/, {
  674. status: 200,
  675. body: {
  676. projects,
  677. totalSize: projects.length,
  678. },
  679. })
  680. window.metaAttributesCache.set(
  681. 'ol-groupsAndEnterpriseBannerVariant',
  682. 'did-you-know'
  683. )
  684. })
  685. afterEach(function () {
  686. fetchMock.reset()
  687. window.metaAttributesCache = window.metaAttributesCache || new Map()
  688. })
  689. it('does not show the banner for users that are in group or are affiliated', async function () {
  690. window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', false)
  691. renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
  692. await fetchMock.flush(true)
  693. expect(screen.queryByRole('link', { name: 'Contact Sales' })).to.be.null
  694. })
  695. it('shows the banner for users that have dismissed the previous banners', async function () {
  696. window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', true)
  697. localStorage.setItem('has_dismissed_groups_and_enterprise_banner', true)
  698. renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
  699. await fetchMock.flush(true)
  700. expect(screen.queryByRole('link', { name: 'Contact Sales' })).to.not.be
  701. .null
  702. })
  703. it('shows the banner for users that have dismissed the banner more than 30 days ago', async function () {
  704. const dismissed = new Date()
  705. dismissed.setDate(dismissed.getDate() - 31) // 31 days
  706. window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', true)
  707. localStorage.setItem(
  708. 'has_dismissed_groups_and_enterprise_banner',
  709. dismissed
  710. )
  711. renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
  712. await fetchMock.flush(true)
  713. expect(screen.queryByRole('link', { name: 'Contact Sales' })).to.not.be
  714. .null
  715. })
  716. it('does not show the banner for users that have dismissed the banner within the last 30 days', async function () {
  717. const dismissed = new Date()
  718. dismissed.setDate(dismissed.getDate() - 29) // 29 days
  719. window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', true)
  720. localStorage.setItem(
  721. 'has_dismissed_groups_and_enterprise_banner',
  722. dismissed
  723. )
  724. renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
  725. await fetchMock.flush(true)
  726. expect(screen.queryByRole('link', { name: 'Contact Sales' })).to.be.null
  727. })
  728. describe('users that are not in group and are not affiliated', function () {
  729. beforeEach(function () {
  730. localStorage.clear()
  731. fetchMock.reset()
  732. // at least one project is required to show some notifications
  733. const projects = [{}] as Project[]
  734. fetchMock.post(/\/api\/project/, {
  735. status: 200,
  736. body: {
  737. projects,
  738. totalSize: projects.length,
  739. },
  740. })
  741. window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', true)
  742. })
  743. afterEach(function () {
  744. fetchMock.reset()
  745. window.metaAttributesCache = window.metaAttributesCache || new Map()
  746. })
  747. after(function () {
  748. localStorage.clear()
  749. })
  750. it('will show the correct text for the `did-you-know` variant', async function () {
  751. window.metaAttributesCache.set(
  752. 'ol-groupsAndEnterpriseBannerVariant',
  753. 'did-you-know'
  754. )
  755. renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
  756. await fetchMock.flush(true)
  757. screen.getByText(
  758. 'Did you know that Overleaf offers group and organization-wide subscription options? Request information or a quote.'
  759. )
  760. const link = screen.getByRole('link', { name: 'Contact Sales' })
  761. expect(link.getAttribute('href')).to.equal(`/for/contact-sales-1`)
  762. })
  763. it('will show the correct text for the `on-premise` variant', async function () {
  764. window.metaAttributesCache.set(
  765. 'ol-groupsAndEnterpriseBannerVariant',
  766. 'on-premise'
  767. )
  768. renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
  769. await fetchMock.flush(true)
  770. screen.getByText(
  771. '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.'
  772. )
  773. const link = screen.getByRole('link', { name: 'Contact Sales' })
  774. expect(link.getAttribute('href')).to.equal(`/for/contact-sales-2`)
  775. })
  776. it('will show the correct text for the `people` variant', async function () {
  777. window.metaAttributesCache.set(
  778. 'ol-groupsAndEnterpriseBannerVariant',
  779. 'people'
  780. )
  781. renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
  782. await fetchMock.flush(true)
  783. screen.getByText(
  784. 'Other people at your company may already be using Overleaf. Save money with Overleaf group and company-wide subscriptions. Request more information.'
  785. )
  786. const link = screen.getByRole('link', { name: 'Contact Sales' })
  787. expect(link.getAttribute('href')).to.equal(`/for/contact-sales-3`)
  788. })
  789. it('will show the correct text for the `FOMO` variant', async function () {
  790. window.metaAttributesCache.set(
  791. 'ol-groupsAndEnterpriseBannerVariant',
  792. 'FOMO'
  793. )
  794. renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
  795. await fetchMock.flush(true)
  796. screen.getByText(
  797. 'Why do Fortune 500 companies and top research institutions trust Overleaf to streamline their collaboration? Get in touch to learn more.'
  798. )
  799. const link = screen.getByRole('link', { name: 'Contact Sales' })
  800. expect(link.getAttribute('href')).to.equal(`/for/contact-sales-4`)
  801. })
  802. })
  803. })
  804. describe('<WritefullPromoBanner>', function () {
  805. beforeEach(function () {
  806. window.metaAttributesCache = window.metaAttributesCache || new Map()
  807. window.metaAttributesCache.set('ol-ExposedSettings', exposedSettings)
  808. window.metaAttributesCache.set('ol-showWritefullPromoBanner', true)
  809. // The older banner is only shown to Chrome users
  810. const navigator = window.navigator as any
  811. navigator.userAgentData = { brands: [{ brand: 'Chromium' }] }
  812. localStorage.clear()
  813. })
  814. afterEach(function () {
  815. window.metaAttributesCache = window.metaAttributesCache || new Map()
  816. })
  817. describe('when writefull-integration split test is not enabled', function () {
  818. beforeEach(function () {
  819. window.metaAttributesCache.set('ol-splitTestVariants', {
  820. 'writefull-integration': 'default',
  821. })
  822. window.metaAttributesCache.set('ol-user', {
  823. writefull: { enabled: false },
  824. })
  825. })
  826. it('shows the older banner', function () {
  827. renderWithinProjectListProvider(UserNotifications)
  828. const ctaLink = screen.getByRole('link', {
  829. name: 'Get Writefull for Overleaf',
  830. })
  831. expect(ctaLink.getAttribute('href')).to.equal(
  832. 'https://my.writefull.com/overleaf-invite?code=OVERLEAF10'
  833. )
  834. })
  835. it('dismisses the banner when the close button is clicked', function () {
  836. renderWithinProjectListProvider(UserNotifications)
  837. screen.getByRole('link', { name: /Writefull/ })
  838. const closeButton = screen.getByRole('button', { name: 'Close' })
  839. fireEvent.click(closeButton)
  840. expect(screen.queryByRole('link', { name: /Writefull/ })).to.be.null
  841. expect(localStorage.getItem('has_dismissed_writefull_promo_banner')).to
  842. .exist
  843. })
  844. it("doesn't show the banner if it has been dismissed", function () {
  845. localStorage.setItem(
  846. 'has_dismissed_writefull_promo_banner',
  847. new Date(Date.now() - 1000)
  848. )
  849. renderWithinProjectListProvider(UserNotifications)
  850. expect(screen.queryByRole('link', { name: /Writefull/ })).to.be.null
  851. })
  852. })
  853. describe('when writefull-integration split test is enabled', function () {
  854. beforeEach(function () {
  855. window.metaAttributesCache.set('ol-splitTestVariants', {
  856. 'writefull-integration': 'enabled',
  857. })
  858. })
  859. describe('when the writefull integration is enabled', function () {
  860. beforeEach(function () {
  861. window.metaAttributesCache.set('ol-user', {
  862. writefull: { enabled: true },
  863. })
  864. })
  865. it('shows the banner', function () {
  866. renderWithinProjectListProvider(UserNotifications)
  867. const ctaLink = screen.getByRole('link', {
  868. name: 'Get Writefull Premium',
  869. })
  870. expect(ctaLink.getAttribute('href')).to.equal(
  871. 'https://my.writefull.com/overleaf-invite?code=OVERLEAF10&redirect=plans'
  872. )
  873. })
  874. it('dismisses the banner when the close button is clicked', function () {
  875. renderWithinProjectListProvider(UserNotifications)
  876. screen.getByRole('link', { name: /Writefull/ })
  877. const closeButton = screen.getByRole('button', { name: 'Close' })
  878. fireEvent.click(closeButton)
  879. expect(screen.queryByRole('link', { name: /Writefull/ })).to.be.null
  880. expect(localStorage.getItem('has_dismissed_writefull_promo_banner'))
  881. .to.exist
  882. })
  883. it("doesn't show the banner if it has been dismissed", function () {
  884. localStorage.setItem(
  885. 'has_dismissed_writefull_promo_banner',
  886. new Date(Date.now() - 500)
  887. )
  888. renderWithinProjectListProvider(UserNotifications)
  889. expect(screen.queryByRole('link', { name: /Writefull/ })).to.be.null
  890. })
  891. })
  892. describe('when the writefull integration is not enabled', function () {
  893. beforeEach(function () {
  894. window.metaAttributesCache.set('ol-user', {
  895. writefull: { enabled: false },
  896. })
  897. })
  898. it("doesn't show the banner", function () {
  899. renderWithinProjectListProvider(UserNotifications)
  900. expect(screen.queryByRole('link', { name: /Writefull/ })).to.be.null
  901. })
  902. })
  903. })
  904. })
  905. describe('GroupSsoSetupSuccess', function () {
  906. beforeEach(function () {
  907. window.metaAttributesCache = window.metaAttributesCache || new Map()
  908. })
  909. afterEach(function () {
  910. window.metaAttributesCache = window.metaAttributesCache || new Map()
  911. })
  912. it('shows group SSO linked notification', function () {
  913. window.metaAttributesCache.set('ol-groupSsoSetupSuccess', true)
  914. renderWithinProjectListProvider(GroupSsoSetupSuccess)
  915. screen.getByText('Success! Single sign-on is all set up for you.')
  916. })
  917. it('does not show group SSO linked notification', function () {
  918. window.metaAttributesCache.set('ol-groupSsoSetupSuccess', false)
  919. renderWithinProjectListProvider(GroupSsoSetupSuccess)
  920. expect(
  921. screen.queryByText('Success! Single sign-on is all set up for you.')
  922. ).to.be.null
  923. })
  924. })
  925. })