emails-section-add-new-email.test.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712
  1. import {
  2. render,
  3. screen,
  4. fireEvent,
  5. waitForElementToBeRemoved,
  6. } from '@testing-library/react'
  7. import userEvent from '@testing-library/user-event'
  8. import EmailsSection from '../../../../../../frontend/js/features/settings/components/emails-section'
  9. import { expect } from 'chai'
  10. import fetchMock from 'fetch-mock'
  11. import { UserEmailData } from '../../../../../../types/user-email'
  12. import { Affiliation } from '../../../../../../types/affiliation'
  13. import withMarkup from '../../../../helpers/with-markup'
  14. import getMeta from '@/utils/meta'
  15. const userEmailData: UserEmailData & { affiliation: Affiliation } = {
  16. affiliation: {
  17. cachedConfirmedAt: null,
  18. cachedEntitlement: null,
  19. cachedLastDayToReconfirm: null,
  20. cachedPastReconfirmDate: false,
  21. cachedReconfirmedAt: null,
  22. department: 'Art History',
  23. institution: {
  24. commonsAccount: false,
  25. writefullCommonsAccount: false,
  26. confirmed: true,
  27. id: 1,
  28. isUniversity: true,
  29. maxConfirmationMonths: null,
  30. name: 'Overleaf',
  31. ssoEnabled: false,
  32. ssoBeta: false,
  33. },
  34. inReconfirmNotificationPeriod: false,
  35. inferred: false,
  36. licence: 'pro_plus',
  37. pastReconfirmDate: false,
  38. portal: { slug: '', templates_count: 1 },
  39. role: 'Reader',
  40. },
  41. email: 'baz@overleaf.com',
  42. default: false,
  43. }
  44. const institutionDomainData = [
  45. {
  46. university: {
  47. id: 1234,
  48. ssoEnabled: true,
  49. name: 'Auto Complete University',
  50. },
  51. hostname: 'autocomplete.edu',
  52. confirmed: true,
  53. },
  54. ] as const
  55. function resetFetchMock() {
  56. fetchMock.removeRoutes().clearHistory()
  57. fetchMock.get('express:/institutions/domains', [])
  58. }
  59. async function confirmCodeForEmail(email: string) {
  60. await screen.findByText(
  61. `Enter the 6-digit confirmation code sent to ${email}.`
  62. )
  63. const inputCode = screen.getByLabelText(/6-digit confirmation code/i)
  64. fireEvent.change(inputCode, { target: { value: '123456' } })
  65. const submitCodeBtn = screen.getByRole<HTMLButtonElement>('button', {
  66. name: 'Confirm',
  67. })
  68. fireEvent.click(submitCodeBtn)
  69. await waitForElementToBeRemoved(() =>
  70. screen.getByRole('button', { name: /confirming/i })
  71. )
  72. }
  73. describe('<EmailsSection />', function () {
  74. beforeEach(function () {
  75. Object.assign(getMeta('ol-ExposedSettings'), {
  76. hasAffiliationsFeature: true,
  77. hasSamlFeature: true,
  78. samlInitPath: 'saml/init',
  79. })
  80. fetchMock.removeRoutes().clearHistory()
  81. })
  82. afterEach(function () {
  83. resetFetchMock()
  84. })
  85. it('renders "add another email" button', async function () {
  86. fetchMock.get('/user/emails?ensureAffiliation=true', [])
  87. render(<EmailsSection />)
  88. await screen.findByRole('button', { name: /add another email/i })
  89. })
  90. it('renders input', async function () {
  91. fetchMock.get('/user/emails?ensureAffiliation=true', [])
  92. render(<EmailsSection />)
  93. await fetchMock.callHistory.flush(true)
  94. const button = await screen.findByRole<HTMLButtonElement>('button', {
  95. name: /add another email/i,
  96. })
  97. fireEvent.click(button)
  98. await screen.findByLabelText(/email/i, { selector: 'input' })
  99. })
  100. it('renders "Start adding your address" until a valid email is typed', async function () {
  101. fetchMock.get('/user/emails?ensureAffiliation=true', [])
  102. fetchMock.get(`/institutions/domains?hostname=email.com&limit=1`, 200)
  103. fetchMock.get(`/institutions/domains?hostname=email&limit=1`, 200)
  104. render(<EmailsSection />)
  105. await fetchMock.callHistory.flush(true)
  106. const button = await screen.findByRole<HTMLButtonElement>('button', {
  107. name: /add another email/i,
  108. })
  109. fireEvent.click(button)
  110. const input = screen.getByLabelText(/email/i, { selector: 'input' })
  111. // initially the text is displayed and the "add email" button disabled
  112. screen.getByText('Start by adding your email address.')
  113. expect(
  114. screen.getByRole<HTMLButtonElement>('button', {
  115. name: /add new email/i,
  116. }).disabled
  117. ).to.be.true
  118. // no changes while writing the email address
  119. fireEvent.change(input, {
  120. target: { value: 'partial@email' },
  121. })
  122. screen.getByText('Start by adding your email address.')
  123. expect(
  124. screen.getByRole<HTMLButtonElement>('button', {
  125. name: /add new email/i,
  126. }).disabled
  127. ).to.be.true
  128. // the text is removed when the complete email address is typed, and the "add button" is reenabled
  129. fireEvent.change(input, {
  130. target: { value: 'valid@email.com' },
  131. })
  132. expect(screen.queryByText('Start by adding your email address.')).to.be.null
  133. expect(
  134. screen.getByRole<HTMLButtonElement>('button', {
  135. name: /add new email/i,
  136. }).disabled
  137. ).to.be.false
  138. })
  139. it('renders "add new email" button', async function () {
  140. fetchMock.get('/user/emails?ensureAffiliation=true', [])
  141. render(<EmailsSection />)
  142. const button = await screen.findByRole<HTMLButtonElement>('button', {
  143. name: /add another email/i,
  144. })
  145. fireEvent.click(button)
  146. screen.getByRole('button', { name: /add new email/i })
  147. })
  148. it('prevent users from adding new emails when the limit is reached', async function () {
  149. const emails = []
  150. for (let i = 0; i < 10; i++) {
  151. emails.push({ email: `bar${i}@overleaf.com` })
  152. }
  153. fetchMock.get('/user/emails?ensureAffiliation=true', emails)
  154. render(<EmailsSection />)
  155. const findByTextWithMarkup = withMarkup(screen.findByText)
  156. await findByTextWithMarkup(
  157. 'You can have a maximum of 10 email addresses on this account. To add another email address, please delete an existing one.'
  158. )
  159. expect(screen.queryByRole('button', { name: /add another email/i })).to.not
  160. .exist
  161. })
  162. it('adds new email address', async function () {
  163. fetchMock.get('/user/emails?ensureAffiliation=true', [])
  164. render(<EmailsSection />)
  165. const addAnotherEmailBtn = await screen.findByRole<HTMLButtonElement>(
  166. 'button',
  167. { name: /add another email/i }
  168. )
  169. await fetchMock.callHistory.flush(true)
  170. resetFetchMock()
  171. fetchMock
  172. .get('/user/emails?ensureAffiliation=true', [userEmailData])
  173. .post('/user/emails/secondary', 200)
  174. .post('/user/emails/confirm-secondary', 200)
  175. fireEvent.click(addAnotherEmailBtn)
  176. const input = screen.getByLabelText(/email/i, { selector: 'input' })
  177. fireEvent.change(input, {
  178. target: { value: userEmailData.email },
  179. })
  180. const submitBtn = screen.getByRole<HTMLButtonElement>('button', {
  181. name: /add new email/i,
  182. })
  183. expect(submitBtn.disabled).to.be.false
  184. fireEvent.click(submitBtn)
  185. expect(submitBtn.disabled).to.be.true
  186. await waitForElementToBeRemoved(() =>
  187. screen.getByRole('button', {
  188. name: /Loading/i,
  189. })
  190. )
  191. await confirmCodeForEmail(userEmailData.email)
  192. await screen.findByText(userEmailData.email)
  193. })
  194. it('fails to add add new email address', async function () {
  195. fetchMock.get('/user/emails?ensureAffiliation=true', [])
  196. render(<EmailsSection />)
  197. const addAnotherEmailBtn = await screen.findByRole<HTMLButtonElement>(
  198. 'button',
  199. { name: /add another email/i }
  200. )
  201. await fetchMock.callHistory.flush(true)
  202. resetFetchMock()
  203. fetchMock
  204. .get('/user/emails?ensureAffiliation=true', [])
  205. .post('/user/emails/secondary', 400)
  206. fireEvent.click(addAnotherEmailBtn)
  207. const input = screen.getByLabelText(/email/i, { selector: 'input' })
  208. fireEvent.change(input, {
  209. target: { value: userEmailData.email },
  210. })
  211. const submitBtn = screen.getByRole<HTMLButtonElement>('button', {
  212. name: /add new email/i,
  213. })
  214. expect(submitBtn.disabled).to.be.false
  215. fireEvent.click(submitBtn)
  216. expect(submitBtn.disabled).to.be.true
  217. await screen.findByText(
  218. /Invalid Request. Please correct the data and try again./i
  219. )
  220. expect(submitBtn).to.not.be.null
  221. expect(submitBtn.disabled).to.be.false
  222. })
  223. it('can link email address to an existing SSO institution', async function () {
  224. fetchMock.get('/user/emails?ensureAffiliation=true', [])
  225. render(<EmailsSection />)
  226. const button = await screen.findByRole<HTMLButtonElement>('button', {
  227. name: /add another email/i,
  228. })
  229. await fetchMock.callHistory.flush(true)
  230. fetchMock.removeRoutes().clearHistory()
  231. fetchMock.get('express:/institutions/domains', institutionDomainData)
  232. await userEvent.click(button)
  233. const input = screen.getByLabelText(/email/i, { selector: 'input' })
  234. fireEvent.change(input, {
  235. target: { value: 'user@autocomplete.edu' },
  236. })
  237. await screen.findByRole('button', { name: 'Link accounts and add email' })
  238. })
  239. it('adds new email address with existing institution and custom departments', async function () {
  240. const country = 'Germany'
  241. const customDepartment = 'Custom department'
  242. fetchMock.get('/user/emails?ensureAffiliation=true', [])
  243. render(<EmailsSection />)
  244. const button = await screen.findByRole<HTMLButtonElement>('button', {
  245. name: /add another email/i,
  246. })
  247. await fetchMock.callHistory.flush(true)
  248. resetFetchMock()
  249. await userEvent.click(button)
  250. await userEvent.type(
  251. screen.getByLabelText(/email/i, { selector: 'input' }),
  252. userEmailData.email
  253. )
  254. await userEvent.click(screen.getByRole('button', { name: /let us know/i }))
  255. const universityInput = screen.getByRole<HTMLInputElement>('combobox', {
  256. name: /university/i,
  257. })
  258. expect(universityInput.disabled).to.be.true
  259. fetchMock.get('/institutions/list?country_code=de', [
  260. {
  261. id: userEmailData.affiliation.institution.id,
  262. name: userEmailData.affiliation.institution.name,
  263. country_code: 'de',
  264. departments: [customDepartment],
  265. },
  266. ])
  267. // Select the country from dropdown
  268. await userEvent.type(
  269. screen.getByRole('combobox', {
  270. name: /country/i,
  271. }),
  272. country
  273. )
  274. await userEvent.click(screen.getByText(country))
  275. expect(universityInput.disabled).to.be.false
  276. await fetchMock.callHistory.flush(true)
  277. resetFetchMock()
  278. // Select the university from dropdown
  279. await userEvent.click(universityInput)
  280. await userEvent.click(
  281. await screen.findByText(userEmailData.affiliation.institution.name)
  282. )
  283. const roleInput = screen.getByRole('combobox', { name: /role/i })
  284. await userEvent.type(roleInput, userEmailData.affiliation.role!)
  285. const departmentInput = screen.getByRole('combobox', {
  286. name: /department/i,
  287. })
  288. await userEvent.click(departmentInput)
  289. await userEvent.click(screen.getByText(customDepartment))
  290. const userEmailDataCopy = {
  291. ...userEmailData,
  292. affiliation: {
  293. ...userEmailData.affiliation,
  294. department: customDepartment,
  295. },
  296. }
  297. fetchMock
  298. .get('/user/emails?ensureAffiliation=true', [userEmailDataCopy])
  299. .post('/user/emails/secondary', 200)
  300. .post('/user/emails/confirm-secondary', 200)
  301. await userEvent.click(
  302. screen.getByRole('button', {
  303. name: /add new email/i,
  304. })
  305. )
  306. const request = fetchMock.callHistory.calls(/\/user\/emails/).at(0)
  307. expect(
  308. JSON.parse(request?.options.body?.toString() || '{}')
  309. ).to.deep.include({
  310. email: userEmailData.email,
  311. university: {
  312. id: userEmailData.affiliation?.institution.id,
  313. },
  314. role: userEmailData.affiliation?.role,
  315. department: customDepartment,
  316. })
  317. await screen.findByText(
  318. `Enter the 6-digit confirmation code sent to ${userEmailData.email}.`
  319. )
  320. await confirmCodeForEmail(userEmailData.email)
  321. await screen.findByText(userEmailData.affiliation.role!, { exact: false })
  322. await screen.findByText(customDepartment, { exact: false })
  323. })
  324. it('autocompletes institution name', async function () {
  325. fetchMock.get('/user/emails?ensureAffiliation=true', [])
  326. render(<EmailsSection />)
  327. const button = await screen.findByRole<HTMLButtonElement>('button', {
  328. name: /add another email/i,
  329. })
  330. await fetchMock.callHistory.flush(true)
  331. resetFetchMock()
  332. fetchMock.get('/institutions/list?country_code=de', [
  333. {
  334. id: 1,
  335. name: 'University of Bonn',
  336. },
  337. {
  338. id: 2,
  339. name: 'Bochum institute of Science',
  340. },
  341. ])
  342. // open "add new email" section and click "let us know" to open the Country/University form
  343. await userEvent.click(button)
  344. await userEvent.type(
  345. screen.getByLabelText(/email/i, { selector: 'input' }),
  346. userEmailData.email
  347. )
  348. await userEvent.click(screen.getByRole('button', { name: /let us know/i }))
  349. // select a country
  350. const countryInput = screen.getByRole<HTMLInputElement>('combobox', {
  351. name: /country/i,
  352. })
  353. await userEvent.click(countryInput)
  354. await userEvent.type(countryInput, 'Germ')
  355. await userEvent.click(await screen.findByText('Germany'))
  356. // match several universities on initial typing
  357. const universityInput = screen.getByRole<HTMLInputElement>('combobox', {
  358. name: /university/i,
  359. })
  360. await userEvent.click(universityInput)
  361. await userEvent.type(universityInput, 'bo')
  362. await screen.findByText('University of Bonn')
  363. await screen.findByText('Bochum institute of Science')
  364. // match a single university when typing to refine the search
  365. await userEvent.type(universityInput, 'nn')
  366. await screen.findByText('University of Bonn')
  367. expect(screen.queryByText('Bochum institute of Science')).to.be.null
  368. })
  369. it('adds new email address without existing institution', async function () {
  370. const country = 'Germany'
  371. const countryCode = 'de'
  372. const newUniversity = 'Abcdef'
  373. fetchMock.get('/user/emails?ensureAffiliation=true', [])
  374. render(<EmailsSection />)
  375. const button = await screen.findByRole<HTMLButtonElement>('button', {
  376. name: /add another email/i,
  377. })
  378. await fetchMock.callHistory.flush(true)
  379. resetFetchMock()
  380. await userEvent.click(button)
  381. await userEvent.type(
  382. screen.getByLabelText(/email/i, { selector: 'input' }),
  383. userEmailData.email
  384. )
  385. await userEvent.click(screen.getByRole('button', { name: /let us know/i }))
  386. const universityInput = screen.getByRole<HTMLInputElement>('combobox', {
  387. name: /university/i,
  388. })
  389. expect(universityInput.disabled).to.be.true
  390. fetchMock.get(/\/institutions\/list/, [
  391. {
  392. id: userEmailData.affiliation.institution.id,
  393. name: userEmailData.affiliation.institution.name,
  394. country_code: 'de',
  395. departments: [],
  396. },
  397. ])
  398. // Select the country from dropdown
  399. await userEvent.type(
  400. screen.getByRole('combobox', {
  401. name: /country/i,
  402. }),
  403. country
  404. )
  405. await userEvent.click(screen.getByText(country))
  406. expect(universityInput.disabled).to.be.false
  407. await fetchMock.callHistory.flush(true)
  408. resetFetchMock()
  409. // Enter the university manually
  410. await userEvent.type(universityInput, newUniversity)
  411. const roleInput = screen.getByRole('combobox', { name: /role/i })
  412. await userEvent.type(roleInput, userEmailData.affiliation.role!)
  413. const departmentInput = screen.getByRole('combobox', {
  414. name: /department/i,
  415. })
  416. await userEvent.type(departmentInput, userEmailData.affiliation.department!)
  417. const userEmailDataCopy = {
  418. ...userEmailData,
  419. affiliation: {
  420. ...userEmailData.affiliation,
  421. institution: {
  422. ...userEmailData.affiliation.institution,
  423. name: newUniversity,
  424. },
  425. },
  426. }
  427. fetchMock
  428. .get('/user/emails?ensureAffiliation=true', [userEmailDataCopy])
  429. .post('/user/emails/secondary', 200)
  430. .post('/user/emails/confirm-secondary', 200)
  431. await userEvent.click(
  432. screen.getByRole('button', {
  433. name: /add new email/i,
  434. })
  435. )
  436. await confirmCodeForEmail(userEmailData.email)
  437. const request = fetchMock.callHistory.calls(/\/user\/emails/).at(0)
  438. expect(
  439. JSON.parse(request?.options.body?.toString() || '{}')
  440. ).to.deep.include({
  441. email: userEmailData.email,
  442. university: {
  443. name: newUniversity,
  444. country_code: countryCode,
  445. },
  446. role: userEmailData.affiliation?.role,
  447. department: userEmailData.affiliation?.department,
  448. })
  449. await screen.findByText(userEmailData.email)
  450. await screen.findByText(newUniversity)
  451. await screen.findByText(userEmailData.affiliation.role!, { exact: false })
  452. await screen.findByText(userEmailData.affiliation.department!, {
  453. exact: false,
  454. })
  455. })
  456. it('shows country, university, role and department fields based on whether `change` was clicked or not', async function () {
  457. const institutionDomainDataCopy = [
  458. {
  459. ...institutionDomainData[0],
  460. university: {
  461. ...institutionDomainData[0].university,
  462. ssoEnabled: false,
  463. },
  464. },
  465. ]
  466. const hostnameFirstChar = institutionDomainDataCopy[0].hostname.charAt(0)
  467. fetchMock.get('/user/emails?ensureAffiliation=true', [])
  468. render(<EmailsSection />)
  469. const button = await screen.findByRole<HTMLButtonElement>('button', {
  470. name: /add another email/i,
  471. })
  472. await fetchMock.callHistory.flush(true)
  473. fetchMock.removeRoutes().clearHistory()
  474. fetchMock.get(
  475. `/institutions/domains?hostname=${hostnameFirstChar}&limit=1`,
  476. institutionDomainDataCopy
  477. )
  478. await userEvent.click(button)
  479. await userEvent.type(
  480. screen.getByLabelText(/email/i, { selector: 'input' }),
  481. `user@${hostnameFirstChar}`
  482. )
  483. await userEvent.keyboard('{Tab}')
  484. await fetchMock.callHistory.flush(true)
  485. fetchMock.removeRoutes().clearHistory()
  486. expect(
  487. screen.queryByRole('combobox', {
  488. name: /country/i,
  489. })
  490. ).to.be.null
  491. expect(
  492. screen.queryByRole('combobox', {
  493. name: /university/i,
  494. })
  495. ).to.be.null
  496. screen.getByRole('combobox', {
  497. name: /role/i,
  498. })
  499. screen.getByRole('combobox', {
  500. name: /department/i,
  501. })
  502. await userEvent.click(screen.getByRole('button', { name: /change/i }))
  503. screen.getByRole('combobox', {
  504. name: /country/i,
  505. })
  506. screen.getByRole('combobox', {
  507. name: /university/i,
  508. })
  509. expect(
  510. screen.queryByRole('combobox', {
  511. name: /role/i,
  512. })
  513. ).to.be.null
  514. expect(
  515. screen.queryByRole('combobox', {
  516. name: /department/i,
  517. })
  518. ).to.be.null
  519. })
  520. it('displays institution name with change button when autocompleted and adds new record', async function () {
  521. const institutionDomainDataCopy = [
  522. {
  523. ...institutionDomainData[0],
  524. university: {
  525. ...institutionDomainData[0].university,
  526. ssoEnabled: false,
  527. },
  528. },
  529. ]
  530. const hostnameFirstChar = institutionDomainDataCopy[0].hostname.charAt(0)
  531. fetchMock.get('/user/emails?ensureAffiliation=true', [])
  532. render(<EmailsSection />)
  533. const button = await screen.findByRole<HTMLButtonElement>('button', {
  534. name: /add another email/i,
  535. })
  536. await fetchMock.callHistory.flush(true)
  537. fetchMock.removeRoutes().clearHistory()
  538. fetchMock.get(
  539. `/institutions/domains?hostname=${hostnameFirstChar}&limit=1`,
  540. institutionDomainDataCopy
  541. )
  542. await userEvent.click(button)
  543. await userEvent.type(
  544. screen.getByLabelText(/email/i, { selector: 'input' }),
  545. `user@${hostnameFirstChar}`
  546. )
  547. await userEvent.keyboard('{Tab}')
  548. await fetchMock.callHistory.flush(true)
  549. fetchMock.removeRoutes().clearHistory()
  550. screen.getByText(institutionDomainDataCopy[0].university.name)
  551. const userEmailDataCopy = {
  552. ...userEmailData,
  553. affiliation: {
  554. ...userEmailData.affiliation,
  555. institution: {
  556. ...userEmailData.affiliation.institution,
  557. name: institutionDomainDataCopy[0].university.name,
  558. },
  559. },
  560. }
  561. fetchMock
  562. .get('/user/emails?ensureAffiliation=true', [userEmailDataCopy])
  563. .post('/user/emails/secondary', 200)
  564. .post('/user/emails/confirm-secondary', 200)
  565. await userEvent.type(
  566. screen.getByRole('combobox', { name: /role/i }),
  567. userEmailData.affiliation.role!
  568. )
  569. await userEvent.type(
  570. screen.getByRole('combobox', { name: /department/i }),
  571. userEmailData.affiliation.department!
  572. )
  573. await userEvent.click(
  574. screen.getByRole('button', {
  575. name: /add new email/i,
  576. })
  577. )
  578. await confirmCodeForEmail('user@autocomplete.edu')
  579. await fetchMock.callHistory.flush(true)
  580. fetchMock.removeRoutes().clearHistory()
  581. screen.getByText(userEmailDataCopy.affiliation.institution.name, {
  582. exact: false,
  583. })
  584. screen.getByText(userEmailDataCopy.affiliation.role!, { exact: false })
  585. screen.getByText(userEmailDataCopy.affiliation.department!, {
  586. exact: false,
  587. })
  588. })
  589. })