notifications.test.tsx 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008
  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(
  254. /please decrease the size of your project to prevent further errors/i
  255. )
  256. const accountSettings = screen.getByRole('link', {
  257. name: /account settings/i,
  258. })
  259. expect(accountSettings.getAttribute('href')).to.equal('/user/settings')
  260. const closeBtn = screen.getByRole('button', { name: /close/i })
  261. fireEvent.click(closeBtn)
  262. expect(screen.queryByRole('alert')).to.be.null
  263. })
  264. it('shows dropbox duplicate project names warning', async function () {
  265. const reconfiguredNotification: DeepPartial<Notification> = {
  266. _id: 1,
  267. templateKey: 'notification_dropbox_duplicate_project_names',
  268. }
  269. window.metaAttributesCache.set('ol-notifications', [
  270. merge(
  271. cloneDeep(notificationDropboxDuplicateProjectNames),
  272. reconfiguredNotification
  273. ),
  274. ])
  275. renderWithinProjectListProvider(Common)
  276. await fetchMock.flush(true)
  277. fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
  278. screen.getByRole('alert')
  279. screen.getByText(/you have more than one project called/i)
  280. screen.getByText(/make your project names unique/i)
  281. const learnMore = screen.getByRole('link', { name: /learn more/i })
  282. expect(learnMore.getAttribute('href')).to.equal(
  283. '/learn/how-to/Dropbox_Synchronization#Troubleshooting'
  284. )
  285. const closeBtn = screen.getByRole('button', { name: /close/i })
  286. fireEvent.click(closeBtn)
  287. expect(fetchMock.called()).to.be.true
  288. expect(screen.queryByRole('alert')).to.be.null
  289. })
  290. it('shows dropbox unlinked tue to lapsed reconfirmation', async function () {
  291. const reconfiguredNotification: DeepPartial<Notification> = {
  292. _id: 1,
  293. templateKey:
  294. 'notification_dropbox_unlinked_due_to_lapsed_reconfirmation',
  295. }
  296. window.metaAttributesCache.set('ol-notifications', [
  297. merge(
  298. cloneDeep(notificationDropboxDuplicateProjectNames),
  299. reconfiguredNotification
  300. ),
  301. ])
  302. renderWithinProjectListProvider(Common)
  303. await fetchMock.flush(true)
  304. fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
  305. screen.getByRole('alert')
  306. screen.getByText(/your Dropbox account has been unlinked/i)
  307. screen.getByText(
  308. /confirm you are still at the institution and on their license, or upgrade your account in order to relink your dropbox account/i
  309. )
  310. const learnMore = screen.getByRole('link', { name: /learn more/i })
  311. expect(learnMore.getAttribute('href')).to.equal(
  312. '/learn/how-to/Institutional_Email_Reconfirmation'
  313. )
  314. const closeBtn = screen.getByRole('button', { name: /close/i })
  315. fireEvent.click(closeBtn)
  316. expect(fetchMock.called()).to.be.true
  317. expect(screen.queryByRole('alert')).to.be.null
  318. })
  319. it('shows non specific notification', async function () {
  320. const reconfiguredNotification: DeepPartial<Notification> = {
  321. _id: 1,
  322. html: 'unspecific message',
  323. }
  324. window.metaAttributesCache.set('ol-notifications', [
  325. reconfiguredNotification,
  326. ])
  327. renderWithinProjectListProvider(Common)
  328. await fetchMock.flush(true)
  329. fetchMock.delete(`/notifications/${reconfiguredNotification._id}`, 200)
  330. screen.getByRole('alert')
  331. screen.getByText(reconfiguredNotification.html!)
  332. const closeBtn = screen.getByRole('button', { name: /close/i })
  333. fireEvent.click(closeBtn)
  334. expect(fetchMock.called()).to.be.true
  335. expect(screen.queryByRole('alert')).to.be.null
  336. })
  337. describe('<GroupInvitation />', function () {
  338. describe('without existing personal subscription', function () {
  339. it('shows group invitation notification for user without personal subscription', async function () {
  340. const notificationGroupInvite: DeepPartial<Notification> = {
  341. _id: 1,
  342. templateKey: 'notification_group_invitation',
  343. }
  344. window.metaAttributesCache.set('ol-notifications', [
  345. merge(
  346. cloneDeep(notificationGroupInviteDefault),
  347. notificationGroupInvite
  348. ),
  349. ])
  350. renderWithinProjectListProvider(Common)
  351. await fetchMock.flush(true)
  352. fetchMock.delete(`/notifications/${notificationGroupInvite._id}`, 200)
  353. screen.getByRole('alert')
  354. screen.getByText('inviter@overleaf.com')
  355. screen.getByText(
  356. /has invited you to join a group subscription on Overleaf/
  357. )
  358. screen.getByRole('button', { name: 'Join now' })
  359. screen.getByRole('button', { name: /close/i })
  360. })
  361. describe('with existing personal subscription', function () {
  362. it('shows group invitation notification for user with personal subscription', async function () {
  363. const notificationGroupInvite: DeepPartial<Notification> = {
  364. _id: 1,
  365. templateKey: 'notification_group_invitation',
  366. }
  367. window.metaAttributesCache.set('ol-notifications', [
  368. merge(
  369. cloneDeep(notificationGroupInviteDefault),
  370. notificationGroupInvite
  371. ),
  372. ])
  373. window.metaAttributesCache.set(
  374. 'ol-hasIndividualRecurlySubscription',
  375. true
  376. )
  377. renderWithinProjectListProvider(Common)
  378. await fetchMock.flush(true)
  379. fetchMock.delete(
  380. `/notifications/${notificationGroupInvite._id}`,
  381. 200
  382. )
  383. screen.getByRole('alert')
  384. screen.getByText(
  385. /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/
  386. )
  387. screen.getByRole('button', { name: 'Not now' })
  388. screen.getByRole('button', { name: 'Cancel my subscription' })
  389. })
  390. })
  391. })
  392. })
  393. })
  394. describe('<Institution>', function () {
  395. beforeEach(function () {
  396. Object.assign(getMeta('ol-ExposedSettings'), exposedSettings)
  397. fetchMock.reset()
  398. })
  399. afterEach(function () {
  400. fetchMock.reset()
  401. })
  402. it('shows sso available', function () {
  403. const institution: DeepPartial<InstitutionType> = {
  404. templateKey: 'notification_institution_sso_available',
  405. }
  406. window.metaAttributesCache.set('ol-notificationsInstitution', [
  407. { ...notificationsInstitution, ...institution },
  408. ])
  409. render(<Institution />)
  410. screen.getByRole('alert')
  411. screen.getByText(/you can now link/i)
  412. screen.getByText(/doing this will allow you to log in/i)
  413. const learnMore = screen.getByRole('link', { name: /learn more/i })
  414. expect(learnMore.getAttribute('href')).to.equal(
  415. '/learn/how-to/Institutional_Login'
  416. )
  417. const action = screen.getByRole('link', { name: /link account/i })
  418. expect(action.getAttribute('href')).to.equal(
  419. `${exposedSettings.samlInitPath}?university_id=${notificationsInstitution.institutionId}&auto=/project&email=${notificationsInstitution.email}`
  420. )
  421. })
  422. it('shows sso linked', function () {
  423. const institution: DeepPartial<InstitutionType> = {
  424. _id: 1,
  425. templateKey: 'notification_institution_sso_linked',
  426. }
  427. window.metaAttributesCache.set('ol-notificationsInstitution', [
  428. { ...notificationsInstitution, ...institution },
  429. ])
  430. render(<Institution />)
  431. fetchMock.delete(`/notifications/${institution._id}`, 200)
  432. screen.getByRole('alert')
  433. screen.getByText(/has been linked to your/i)
  434. const closeBtn = screen.getByRole('button', { name: /close/i })
  435. fireEvent.click(closeBtn)
  436. expect(fetchMock.called()).to.be.true
  437. expect(screen.queryByRole('alert')).to.be.null
  438. })
  439. it('shows sso non canonical', function () {
  440. const institution: DeepPartial<InstitutionType> = {
  441. _id: 1,
  442. templateKey: 'notification_institution_sso_non_canonical',
  443. }
  444. window.metaAttributesCache.set('ol-notificationsInstitution', [
  445. { ...notificationsInstitution, ...institution },
  446. ])
  447. render(<Institution />)
  448. fetchMock.delete(`/notifications/${institution._id}`, 200)
  449. screen.getByRole('alert')
  450. screen.getByText(/you’ve tried to log in with/i)
  451. screen.getByText(
  452. /in order to match your institutional metadata, your account is associated with/i
  453. )
  454. const closeBtn = screen.getByRole('button', { name: /close/i })
  455. fireEvent.click(closeBtn)
  456. expect(fetchMock.called()).to.be.true
  457. expect(screen.queryByRole('alert')).to.be.null
  458. })
  459. it('shows sso already registered', function () {
  460. const institution: DeepPartial<InstitutionType> = {
  461. _id: 1,
  462. templateKey: 'notification_institution_sso_already_registered',
  463. }
  464. window.metaAttributesCache.set('ol-notificationsInstitution', [
  465. { ...notificationsInstitution, ...institution },
  466. ])
  467. render(<Institution />)
  468. fetchMock.delete(`/notifications/${institution._id}`, 200)
  469. screen.getByRole('alert')
  470. screen.getByText(/which is already registered with/i)
  471. const action = screen.getByRole('link', { name: /find out more/i })
  472. expect(action.getAttribute('href')).to.equal(
  473. '/learn/how-to/Institutional_Login'
  474. )
  475. const closeBtn = screen.getByRole('button', { name: /close/i })
  476. fireEvent.click(closeBtn)
  477. expect(fetchMock.called()).to.be.true
  478. expect(screen.queryByRole('alert')).to.be.null
  479. })
  480. it('shows sso error', function () {
  481. const institution: DeepPartial<InstitutionType> = {
  482. templateKey: 'notification_institution_sso_error',
  483. error: {
  484. message: 'fake message',
  485. tryAgain: true,
  486. },
  487. }
  488. window.metaAttributesCache.set('ol-notificationsInstitution', [
  489. { ...notificationsInstitution, ...institution },
  490. ])
  491. render(<Institution />)
  492. screen.getByRole('alert')
  493. screen.getByText(/something went wrong/i)
  494. screen.getByText(institution.error!.message!)
  495. screen.getByText(/please try again/i)
  496. const closeBtn = screen.getByRole('button', { name: /close/i })
  497. fireEvent.click(closeBtn)
  498. expect(screen.queryByRole('alert')).to.be.null
  499. })
  500. })
  501. describe('<ConfirmEmail/>', function () {
  502. beforeEach(async function () {
  503. Object.assign(getMeta('ol-ExposedSettings'), {
  504. emailConfirmationDisabled: false,
  505. })
  506. window.metaAttributesCache.set('ol-userEmails', [unconfirmedUserData])
  507. window.metaAttributesCache.set(
  508. 'ol-usersBestSubscription',
  509. freeSubscription
  510. )
  511. })
  512. afterEach(function () {
  513. fetchMock.reset()
  514. })
  515. it('sends successfully', async function () {
  516. renderWithinProjectListProvider(ConfirmEmail)
  517. await fetchMock.flush(true)
  518. fetchMock.post('/user/emails/resend_confirmation', 200)
  519. const email = unconfirmedUserData.email
  520. const notificationBody = screen.getByTestId('pro-notification-body')
  521. expect(notificationBody.textContent).to.contain(
  522. `Please confirm your email ${email} by clicking on the link in the confirmation email`
  523. )
  524. const resendButton = screen.getByRole('button', { name: /resend/i })
  525. fireEvent.click(resendButton)
  526. expect(screen.queryByRole('button', { name: /resend/i })).to.be.null
  527. await waitForElementToBeRemoved(() =>
  528. screen.getByText(/resending confirmation email/i)
  529. )
  530. expect(fetchMock.called()).to.be.true
  531. expect(screen.queryByRole('alert')).to.be.null
  532. })
  533. it('fails to send', async function () {
  534. renderWithinProjectListProvider(ConfirmEmail)
  535. await fetchMock.flush(true)
  536. fetchMock.post('/user/emails/resend_confirmation', 500)
  537. const resendButton = screen.getByRole('button', { name: /resend/i })
  538. fireEvent.click(resendButton)
  539. await waitForElementToBeRemoved(() =>
  540. screen.getByText(/resending confirmation email/i)
  541. )
  542. expect(fetchMock.called()).to.be.true
  543. screen.getByText(/something went wrong/i)
  544. })
  545. for (const subscription of [freeSubscription, individualSubscription]) {
  546. it(`shows commons notification for commons account when user is on ${subscription.type} plan`, async function () {
  547. window.metaAttributesCache.set('ol-userEmails', [
  548. unconfirmedCommonsUserData,
  549. ])
  550. window.metaAttributesCache.set('ol-usersBestSubscription', subscription)
  551. renderWithinProjectListProvider(ConfirmEmail)
  552. await fetchMock.flush(true)
  553. const alert = screen.getByRole('alert')
  554. const email = unconfirmedCommonsUserData.email
  555. const notificationBody = within(alert).getByTestId('notification-body')
  556. expect(notificationBody.textContent).to.contain(
  557. 'You are one step away from accessing Overleaf Professional features'
  558. )
  559. expect(notificationBody.textContent).to.contain(
  560. `Overleaf has an Overleaf subscription. Click the confirmation link sent to ${email} to upgrade to Overleaf Professional`
  561. )
  562. })
  563. }
  564. for (const subscription of [groupSubscription, commonsSubscription]) {
  565. it(`shows default notification for commons account when user is on ${subscription.type} plan`, async function () {
  566. window.metaAttributesCache.set('ol-userEmails', [
  567. unconfirmedCommonsUserData,
  568. ])
  569. window.metaAttributesCache.set('ol-usersBestSubscription', subscription)
  570. renderWithinProjectListProvider(ConfirmEmail)
  571. await fetchMock.flush(true)
  572. const alert = screen.getByRole('alert')
  573. const email = unconfirmedCommonsUserData.email
  574. const notificationBody = within(alert).getByTestId(
  575. 'pro-notification-body'
  576. )
  577. expect(notificationBody.textContent).to.contain(
  578. `Please confirm your email ${email} by clicking on the link in the confirmation email`
  579. )
  580. })
  581. }
  582. })
  583. describe('<Affiliation/>', function () {
  584. let assignStub: sinon.SinonStub
  585. beforeEach(function () {
  586. Object.assign(getMeta('ol-ExposedSettings'), exposedSettings)
  587. assignStub = sinon.stub()
  588. this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
  589. assign: assignStub,
  590. replace: sinon.stub(),
  591. reload: sinon.stub(),
  592. })
  593. fetchMock.reset()
  594. })
  595. afterEach(function () {
  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. localStorage.clear()
  669. fetchMock.reset()
  670. // at least one project is required to show some notifications
  671. const projects = [{}] as Project[]
  672. fetchMock.post(/\/api\/project/, {
  673. status: 200,
  674. body: {
  675. projects,
  676. totalSize: projects.length,
  677. },
  678. })
  679. window.metaAttributesCache.set(
  680. 'ol-groupsAndEnterpriseBannerVariant',
  681. 'on-premise'
  682. )
  683. })
  684. afterEach(function () {
  685. fetchMock.reset()
  686. })
  687. it('does not show the banner for users that are in group or are affiliated', async function () {
  688. window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', false)
  689. renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
  690. await fetchMock.flush(true)
  691. expect(screen.queryByRole('link', { name: 'Contact Sales' })).to.be.null
  692. })
  693. it('shows the banner for users that have dismissed the previous banners', async function () {
  694. window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', true)
  695. localStorage.setItem('has_dismissed_groups_and_enterprise_banner', true)
  696. renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
  697. await fetchMock.flush(true)
  698. expect(screen.queryByRole('link', { name: 'Contact Sales' })).to.not.be
  699. .null
  700. })
  701. it('shows the banner for users that have dismissed the banner more than 30 days ago', async function () {
  702. const dismissed = new Date()
  703. dismissed.setDate(dismissed.getDate() - 31) // 31 days
  704. window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', true)
  705. localStorage.setItem(
  706. 'has_dismissed_groups_and_enterprise_banner',
  707. dismissed
  708. )
  709. renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
  710. await fetchMock.flush(true)
  711. expect(screen.queryByRole('link', { name: 'Contact Sales' })).to.not.be
  712. .null
  713. })
  714. it('does not show the banner for users that have dismissed the banner within the last 30 days', async function () {
  715. const dismissed = new Date()
  716. dismissed.setDate(dismissed.getDate() - 29) // 29 days
  717. window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', true)
  718. localStorage.setItem(
  719. 'has_dismissed_groups_and_enterprise_banner',
  720. dismissed
  721. )
  722. renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
  723. await fetchMock.flush(true)
  724. expect(screen.queryByRole('link', { name: 'Contact Sales' })).to.be.null
  725. })
  726. describe('users that are not in group and are not affiliated', function () {
  727. beforeEach(function () {
  728. localStorage.clear()
  729. fetchMock.reset()
  730. // at least one project is required to show some notifications
  731. const projects = [{}] as Project[]
  732. fetchMock.post(/\/api\/project/, {
  733. status: 200,
  734. body: {
  735. projects,
  736. totalSize: projects.length,
  737. },
  738. })
  739. window.metaAttributesCache.set('ol-showGroupsAndEnterpriseBanner', true)
  740. })
  741. afterEach(function () {
  742. fetchMock.reset()
  743. })
  744. after(function () {
  745. localStorage.clear()
  746. })
  747. it('will show the correct text for the `on-premise` variant', async function () {
  748. window.metaAttributesCache.set(
  749. 'ol-groupsAndEnterpriseBannerVariant',
  750. 'on-premise'
  751. )
  752. renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
  753. await fetchMock.flush(true)
  754. screen.getByText(
  755. '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.'
  756. )
  757. const link = screen.getByRole('link', { name: 'Contact Sales' })
  758. expect(link.getAttribute('href')).to.equal(`/for/contact-sales-2`)
  759. })
  760. it('will show the correct text for the `FOMO` variant', async function () {
  761. window.metaAttributesCache.set(
  762. 'ol-groupsAndEnterpriseBannerVariant',
  763. 'FOMO'
  764. )
  765. renderWithinProjectListProvider(GroupsAndEnterpriseBanner)
  766. await fetchMock.flush(true)
  767. screen.getByText(
  768. 'Why do Fortune 500 companies and top research institutions trust Overleaf to streamline their collaboration? Get in touch to learn more.'
  769. )
  770. const link = screen.getByRole('link', { name: 'Contact Sales' })
  771. expect(link.getAttribute('href')).to.equal(`/for/contact-sales-4`)
  772. })
  773. })
  774. })
  775. describe('<WritefullPromoBanner>', function () {
  776. beforeEach(function () {
  777. Object.assign(getMeta('ol-ExposedSettings'), exposedSettings)
  778. window.metaAttributesCache.set('ol-showWritefullPromoBanner', true)
  779. // The older banner is only shown to Chrome users
  780. const navigator = window.navigator as any
  781. navigator.userAgentData = { brands: [{ brand: 'Chromium' }] }
  782. localStorage.clear()
  783. })
  784. describe('when the writefull integration is enabled', function () {
  785. beforeEach(function () {
  786. window.metaAttributesCache.set('ol-user', {
  787. writefull: { enabled: true },
  788. })
  789. })
  790. it('shows the banner', function () {
  791. renderWithinProjectListProvider(UserNotifications)
  792. const ctaLink = screen.getByRole('link', {
  793. name: 'Get Writefull Premium',
  794. })
  795. expect(ctaLink.getAttribute('href')).to.equal(
  796. 'https://my.writefull.com/overleaf-invite?code=OVERLEAF10&redirect=plans'
  797. )
  798. })
  799. it('dismisses the banner when the close button is clicked', function () {
  800. renderWithinProjectListProvider(UserNotifications)
  801. screen.getByRole('link', { name: /Writefull/ })
  802. const WritefullPromoBanner = screen.getByTestId(
  803. 'writefull-premium-promo-banner'
  804. )
  805. const closeButton = within(WritefullPromoBanner).getByRole('button', {
  806. name: 'Close',
  807. })
  808. fireEvent.click(closeButton)
  809. expect(screen.queryByRole('link', { name: /Writefull/ })).to.be.null
  810. expect(localStorage.getItem('has_dismissed_writefull_promo_banner')).to
  811. .exist
  812. })
  813. it("doesn't show the banner if it has been dismissed", function () {
  814. localStorage.setItem(
  815. 'has_dismissed_writefull_promo_banner',
  816. new Date(Date.now() - 500)
  817. )
  818. renderWithinProjectListProvider(UserNotifications)
  819. expect(screen.queryByRole('link', { name: /Writefull/ })).to.be.null
  820. })
  821. })
  822. describe('when the writefull integration is not enabled', function () {
  823. beforeEach(function () {
  824. window.metaAttributesCache.set('ol-user', {
  825. writefull: { enabled: false },
  826. })
  827. })
  828. it("doesn't show the banner", function () {
  829. renderWithinProjectListProvider(UserNotifications)
  830. expect(screen.queryByRole('link', { name: /Writefull/ })).to.be.null
  831. })
  832. })
  833. })
  834. describe('GroupSsoSetupSuccess', function () {
  835. it('shows group SSO linked notification', function () {
  836. window.metaAttributesCache.set('ol-groupSsoSetupSuccess', true)
  837. renderWithinProjectListProvider(GroupSsoSetupSuccess)
  838. screen.getByText('Success! Single sign-on is all set up for you.')
  839. })
  840. it('does not show group SSO linked notification', function () {
  841. window.metaAttributesCache.set('ol-groupSsoSetupSuccess', false)
  842. renderWithinProjectListProvider(GroupSsoSetupSuccess)
  843. expect(
  844. screen.queryByText('Success! Single sign-on is all set up for you.')
  845. ).to.be.null
  846. })
  847. })
  848. })