add-email-input.test.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. import { fireEvent, render, screen, waitFor } from '@testing-library/react'
  2. import { expect } from 'chai'
  3. import sinon from 'sinon'
  4. import fetchMock from 'fetch-mock'
  5. import Input, {
  6. clearDomainCache,
  7. } from '../../../../../../frontend/js/features/settings/components/emails/add-email/input'
  8. import domainBlocklist from '../../../../../../frontend/js/features/settings/domain-blocklist'
  9. const testInstitutionData = [
  10. { university: { id: 124 }, hostname: 'domain.edu' },
  11. ]
  12. describe('<AddEmailInput/>', function () {
  13. const defaultProps = {
  14. onChange: () => {},
  15. handleAddNewEmail: () => {},
  16. }
  17. beforeEach(function () {
  18. clearDomainCache()
  19. fetchMock.removeRoutes().clearHistory()
  20. })
  21. describe('on initial render', function () {
  22. it('should render an initial input', function () {
  23. render(<Input {...defaultProps} />)
  24. screen.getByTestId('affiliations-email')
  25. })
  26. it('should not dispatch any `change` event', function () {
  27. const onChangeStub = sinon.stub()
  28. render(<Input {...defaultProps} onChange={onChangeStub} />)
  29. expect(onChangeStub.called).to.equal(false)
  30. })
  31. })
  32. describe('when typing text that does not contain any potential domain match', function () {
  33. let onChangeStub: sinon.SinonStub
  34. let handleAddNewEmailStub: sinon.SinonStub
  35. beforeEach(function () {
  36. fetchMock.get('express:/institutions/domains', 200)
  37. onChangeStub = sinon.stub()
  38. handleAddNewEmailStub = sinon.stub()
  39. render(
  40. <Input
  41. {...defaultProps}
  42. onChange={onChangeStub}
  43. handleAddNewEmail={handleAddNewEmailStub}
  44. />
  45. )
  46. fireEvent.change(screen.getByTestId('affiliations-email'), {
  47. target: { value: 'user' },
  48. })
  49. })
  50. it('should render the text being typed', function () {
  51. const input = screen.getByTestId('affiliations-email') as HTMLInputElement
  52. expect(input.value).to.equal('user')
  53. })
  54. it('should dispatch a `change` event on every stroke', function () {
  55. expect(onChangeStub.calledWith('user')).to.equal(true)
  56. fireEvent.change(screen.getByTestId('affiliations-email'), {
  57. target: { value: 's' },
  58. })
  59. expect(onChangeStub.calledWith('s')).to.equal(true)
  60. })
  61. it('should not make any request for institution domains', function () {
  62. expect(fetchMock.callHistory.called()).to.be.false
  63. })
  64. it('should submit on Enter if email looks valid', async function () {
  65. fireEvent.change(screen.getByTestId('affiliations-email'), {
  66. target: { value: 'user@domain.com' },
  67. })
  68. fireEvent.keyDown(screen.getByTestId('affiliations-email'), {
  69. key: 'Enter',
  70. })
  71. expect(handleAddNewEmailStub.calledWith()).to.equal(true)
  72. })
  73. it('should not submit on Enter if email does not look valid', async function () {
  74. fireEvent.change(screen.getByTestId('affiliations-email'), {
  75. target: { value: 'user@' },
  76. })
  77. fireEvent.keyDown(screen.getByTestId('affiliations-email'), {
  78. key: 'Enter',
  79. })
  80. expect(handleAddNewEmailStub.calledWith()).to.equal(false)
  81. })
  82. })
  83. describe('when typing text that contains a potential domain match', function () {
  84. let onChangeStub: sinon.SinonStub
  85. beforeEach(function () {
  86. onChangeStub = sinon.stub()
  87. render(<Input {...defaultProps} onChange={onChangeStub} />)
  88. })
  89. describe('when there are no matches', function () {
  90. beforeEach(function () {
  91. fetchMock.get('express:/institutions/domains', 200)
  92. fireEvent.change(screen.getByTestId('affiliations-email'), {
  93. target: { value: 'user@d' },
  94. })
  95. })
  96. it('should render the text being typed', function () {
  97. const input = screen.getByTestId(
  98. 'affiliations-email'
  99. ) as HTMLInputElement
  100. expect(input.value).to.equal('user@d')
  101. })
  102. })
  103. describe('when there is a domain match', function () {
  104. beforeEach(async function () {
  105. fetchMock.get('express:/institutions/domains', testInstitutionData)
  106. const input = await screen.findByTestId('affiliations-email')
  107. fireEvent.change(input, { target: { value: 'user@d' } })
  108. // Wait for the request to complete and the domain cache to pouplate
  109. await waitFor(
  110. () =>
  111. expect(
  112. fetchMock.callHistory.called('express:/institutions/domains')
  113. ).to.be.true
  114. )
  115. // Wait for component to process the change and update the shadow input
  116. await waitFor(() => {
  117. const shadowInput = screen.getByTestId(
  118. 'affiliations-email-shadow'
  119. ) as HTMLInputElement
  120. expect(shadowInput.value).to.equal('user@domain.edu')
  121. })
  122. })
  123. it('should render the text being typed along with the suggestion', async function () {
  124. const input = screen.getByTestId(
  125. 'affiliations-email'
  126. ) as HTMLInputElement
  127. expect(input.value).to.equal('user@d')
  128. await waitFor(() => {
  129. const shadowInput = screen.getByTestId(
  130. 'affiliations-email-shadow'
  131. ) as HTMLInputElement
  132. expect(shadowInput.value).to.equal('user@domain.edu')
  133. })
  134. })
  135. it('should dispatch a `change` event with the typed text', function () {
  136. expect(onChangeStub.calledWith('user@d')).to.equal(true)
  137. })
  138. it('should dispatch a `change` event with institution data when the typed email contains the institution domain', async function () {
  139. fireEvent.change(screen.getByTestId('affiliations-email'), {
  140. target: { value: 'user@domain.edu' },
  141. })
  142. await fetchMock.callHistory.flush(true)
  143. expect(
  144. onChangeStub.calledWith(
  145. 'user@domain.edu',
  146. sinon.match(testInstitutionData[0])
  147. )
  148. ).to.equal(true)
  149. })
  150. it('should clear the suggestion when the potential domain match is completely deleted', async function () {
  151. await waitFor(() => {
  152. const shadowInput = screen.getByTestId(
  153. 'affiliations-email-shadow'
  154. ) as HTMLInputElement
  155. expect(shadowInput.value).to.equal('user@domain.edu')
  156. })
  157. fireEvent.change(screen.getByTestId('affiliations-email'), {
  158. target: { value: '' },
  159. })
  160. expect(screen.queryByText('user@domain.edu')).to.be.null
  161. })
  162. describe('when there is a suggestion and "Tab" key is pressed', function () {
  163. beforeEach(async function () {
  164. // wait until autocompletion available
  165. await waitFor(() => {
  166. const shadowInput = screen.getByTestId(
  167. 'affiliations-email-shadow'
  168. ) as HTMLInputElement
  169. expect(shadowInput.value).to.equal('user@domain.edu')
  170. })
  171. fireEvent.keyDown(screen.getByTestId('affiliations-email'), {
  172. key: 'Tab',
  173. })
  174. })
  175. it('it should autocomplete the input', async function () {
  176. const input = screen.getByTestId(
  177. 'affiliations-email'
  178. ) as HTMLInputElement
  179. expect(input.value).to.equal('user@domain.edu')
  180. })
  181. it('should dispatch a `change` event with the domain matched', async function () {
  182. expect(
  183. onChangeStub.calledWith(
  184. 'user@domain.edu',
  185. sinon.match(testInstitutionData[0])
  186. )
  187. ).to.equal(true)
  188. })
  189. })
  190. describe('when there is a suggestion and "Enter" key is pressed', function () {
  191. beforeEach(async function () {
  192. // wait until autocompletion available
  193. await waitFor(() => {
  194. const shadowInput = screen.getByTestId(
  195. 'affiliations-email-shadow'
  196. ) as HTMLInputElement
  197. expect(shadowInput.value).to.equal('user@domain.edu')
  198. })
  199. fireEvent.keyDown(screen.getByTestId('affiliations-email'), {
  200. key: 'Enter',
  201. })
  202. })
  203. it('it should autocomplete the input', async function () {
  204. const input = screen.getByTestId(
  205. 'affiliations-email'
  206. ) as HTMLInputElement
  207. expect(input.value).to.equal('user@domain.edu')
  208. })
  209. it('should dispatch a `change` event with the domain matched', async function () {
  210. expect(
  211. onChangeStub.calledWith(
  212. 'user@domain.edu',
  213. sinon.match(testInstitutionData[0])
  214. )
  215. ).to.equal(true)
  216. })
  217. })
  218. it('should cache the result and skip subsequent requests', async function () {
  219. fetchMock.removeRoutes().clearHistory()
  220. // clear input
  221. fireEvent.change(screen.getByTestId('affiliations-email'), {
  222. target: { value: '' },
  223. })
  224. // type a hint to trigger the domain search
  225. fireEvent.change(screen.getByTestId('affiliations-email'), {
  226. target: { value: 'user@d' },
  227. })
  228. expect(fetchMock.callHistory.called()).to.be.false
  229. expect(onChangeStub.calledWith('user@d')).to.equal(true)
  230. await waitFor(() => {
  231. const shadowInput = screen.getByTestId(
  232. 'affiliations-email-shadow'
  233. ) as HTMLInputElement
  234. expect(shadowInput.value).to.equal('user@domain.edu')
  235. })
  236. })
  237. })
  238. describe('when there is a match for a blocklisted domain', function () {
  239. const [blockedDomain] = domainBlocklist
  240. afterEach(function () {
  241. clearDomainCache()
  242. fetchMock.removeRoutes().clearHistory()
  243. })
  244. it('should not render the suggestion with blocked domain', async function () {
  245. const blockedInstitution = [
  246. { university: { id: 1 }, hostname: blockedDomain },
  247. ]
  248. fetchMock.get('express:/institutions/domains', blockedInstitution)
  249. fireEvent.change(screen.getByTestId('affiliations-email'), {
  250. target: { value: `user@${blockedDomain.split('.')[0]}` },
  251. })
  252. await fetchMock.callHistory.flush(true)
  253. expect(screen.queryByText(`user@${blockedDomain}`)).to.be.null
  254. })
  255. it('should not render the suggestion with blocked domain having a subdomain', async function () {
  256. const blockedInstitution = [
  257. {
  258. university: { id: 1 },
  259. hostname: `subdomain.${blockedDomain}`,
  260. },
  261. ]
  262. fetchMock.get('express:/institutions/domains', blockedInstitution)
  263. fireEvent.change(screen.getByTestId('affiliations-email'), {
  264. target: {
  265. value: `user@subdomain.${blockedDomain.split('.')[0]}`,
  266. },
  267. })
  268. await fetchMock.callHistory.flush(true)
  269. expect(screen.queryByText(`user@subdomain.${blockedDomain}`)).to.be.null
  270. })
  271. })
  272. describe('while waiting for a response', function () {
  273. beforeEach(async function () {
  274. // type an initial suggestion
  275. fetchMock.get('express:/institutions/domains', testInstitutionData)
  276. fireEvent.change(screen.getByTestId('affiliations-email'), {
  277. target: { value: 'user@d' },
  278. })
  279. await waitFor(() => {
  280. const shadowInput = screen.getByTestId(
  281. 'affiliations-email-shadow'
  282. ) as HTMLInputElement
  283. expect(shadowInput.value).to.equal('user@domain.edu')
  284. })
  285. // make sure the next suggestions are delayed
  286. clearDomainCache()
  287. fetchMock.removeRoutes().clearHistory()
  288. fetchMock.get('express:/institutions/domains', 200, { delay: 1000 })
  289. })
  290. it('should keep the suggestion if the hint matches the previously matched domain', async function () {
  291. fireEvent.change(screen.getByTestId('affiliations-email'), {
  292. target: { value: 'user@do' },
  293. })
  294. const shadowInput = screen.getByTestId(
  295. 'affiliations-email-shadow'
  296. ) as HTMLInputElement
  297. expect(shadowInput.value).to.equal('user@domain.edu')
  298. })
  299. it('should remove the suggestion if the hint does not match the previously matched domain', async function () {
  300. fireEvent.change(screen.getByTestId('affiliations-email'), {
  301. target: { value: 'user@foo' },
  302. })
  303. await waitFor(() => {
  304. const shadowInput = screen.getByTestId(
  305. 'affiliations-email-shadow'
  306. ) as HTMLInputElement
  307. expect(shadowInput.value).to.equal('')
  308. })
  309. })
  310. })
  311. })
  312. describe('when the request to fetch institution domains fail', function () {
  313. let onChangeStub
  314. beforeEach(async function () {
  315. // initial request populates the suggestion
  316. fetchMock.get('express:/institutions/domains', testInstitutionData)
  317. onChangeStub = sinon.stub()
  318. render(<Input {...defaultProps} onChange={onChangeStub} />)
  319. fireEvent.change(screen.getByTestId('affiliations-email'), {
  320. target: { value: 'user@d' },
  321. })
  322. await waitFor(() => {
  323. const shadowInput = screen.getByTestId(
  324. 'affiliations-email-shadow'
  325. ) as HTMLInputElement
  326. expect(shadowInput.value).to.equal('user@domain.edu')
  327. })
  328. // subsequent requests fail
  329. fetchMock.removeRoutes().clearHistory()
  330. fetchMock.get('express:/institutions/domains', 500)
  331. })
  332. it('should clear suggestions', async function () {
  333. fireEvent.change(screen.getByTestId('affiliations-email'), {
  334. target: { value: 'user@dom' },
  335. })
  336. const input = screen.getByTestId('affiliations-email') as HTMLInputElement
  337. expect(input.value).to.equal('user@dom')
  338. await waitFor(() => {
  339. const shadowInput = screen.getByTestId(
  340. 'affiliations-email-shadow'
  341. ) as HTMLInputElement
  342. expect(shadowInput.value).to.equal('')
  343. })
  344. expect(fetchMock.callHistory.called()).to.be.true // ensures `domainCache` hasn't been hit
  345. })
  346. })
  347. describe('when the request to fetch institution is not matching input', function () {
  348. it('should clear suggestion', async function () {
  349. fetchMock.get('express:/institutions/domains', testInstitutionData)
  350. render(<Input {...defaultProps} onChange={sinon.stub()} />)
  351. fireEvent.change(screen.getByTestId('affiliations-email'), {
  352. target: { value: 'user@other' },
  353. })
  354. await fetchMock.callHistory.flush(true)
  355. const shadowInput = screen.getByTestId(
  356. 'affiliations-email-shadow'
  357. ) as HTMLInputElement
  358. expect(shadowInput.value).to.equal('')
  359. })
  360. })
  361. })