notifications.test.tsx 35 KB

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