chat-context.test.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  1. // Disable prop type checks for test harnesses
  2. /* eslint-disable react/prop-types */
  3. import React from 'react'
  4. import { renderHook, act } from '@testing-library/react-hooks/dom'
  5. import { expect } from 'chai'
  6. import fetchMock from 'fetch-mock'
  7. import EventEmitter from 'events'
  8. import { useChatContext } from '../../../../../frontend/js/features/chat/context/chat-context'
  9. import {
  10. ChatProviders,
  11. cleanUpContext,
  12. } from '../../../helpers/render-with-context'
  13. import { stubMathJax, tearDownMathJaxStubs } from '../components/stubs'
  14. describe('ChatContext', function () {
  15. const user = {
  16. id: 'fake_user',
  17. first_name: 'fake_user_first_name',
  18. email: 'fake@example.com',
  19. }
  20. beforeEach(function () {
  21. fetchMock.reset()
  22. cleanUpContext()
  23. stubMathJax()
  24. })
  25. afterEach(function () {
  26. tearDownMathJaxStubs()
  27. })
  28. describe('socket connection', function () {
  29. beforeEach(function () {
  30. // Mock GET messages to return no messages
  31. fetchMock.get('express:/project/:projectId/messages', [])
  32. // Mock POST new message to return 200
  33. fetchMock.post('express:/project/:projectId/messages', 200)
  34. })
  35. it('subscribes when mounted', function () {
  36. const socket = new EventEmitter()
  37. renderChatContextHook({ user, socket })
  38. // Assert that there is 1 listener
  39. expect(socket.rawListeners('new-chat-message').length).to.equal(1)
  40. })
  41. it('unsubscribes when unmounted', function () {
  42. const socket = new EventEmitter()
  43. const { unmount } = renderChatContextHook({ user, socket })
  44. unmount()
  45. // Assert that there is 0 listeners
  46. expect(socket.rawListeners('new-chat-message').length).to.equal(0)
  47. })
  48. it('adds received messages to the list', async function () {
  49. // Mock socket: we only need to emit events, not mock actual connections
  50. const socket = new EventEmitter()
  51. const { result, waitForNextUpdate } = renderChatContextHook({
  52. user,
  53. socket,
  54. })
  55. // Wait until initial messages have loaded
  56. result.current.loadInitialMessages()
  57. await waitForNextUpdate()
  58. // No messages shown at first
  59. expect(result.current.messages).to.deep.equal([])
  60. // Mock message being received from another user
  61. socket.emit('new-chat-message', {
  62. id: 'msg_1',
  63. content: 'new message',
  64. timestamp: Date.now(),
  65. user: {
  66. id: 'another_fake_user',
  67. first_name: 'another_fake_user_first_name',
  68. email: 'another_fake@example.com',
  69. },
  70. })
  71. const message = result.current.messages[0]
  72. expect(message.id).to.equal('msg_1')
  73. expect(message.contents).to.deep.equal(['new message'])
  74. })
  75. it("doesn't add received messages from the current user if a message was just sent", async function () {
  76. const socket = new EventEmitter()
  77. const { result, waitForNextUpdate } = renderChatContextHook({
  78. user,
  79. socket,
  80. })
  81. // Wait until initial messages have loaded
  82. result.current.loadInitialMessages()
  83. await waitForNextUpdate()
  84. // Send a message from the current user
  85. result.current.sendMessage('sent message')
  86. // Receive a message from the current user
  87. socket.emit('new-chat-message', {
  88. id: 'msg_1',
  89. content: 'received message',
  90. timestamp: Date.now(),
  91. user,
  92. })
  93. // Expect that the sent message is shown, but the new message is not
  94. const messageContents = result.current.messages.map(
  95. ({ contents }) => contents[0]
  96. )
  97. expect(messageContents).to.include('sent message')
  98. expect(messageContents).to.not.include('received message')
  99. })
  100. it('adds the new message from the current user if another message was received after sending', async function () {
  101. const socket = new EventEmitter()
  102. const { result, waitForNextUpdate } = renderChatContextHook({
  103. user,
  104. socket,
  105. })
  106. // Wait until initial messages have loaded
  107. result.current.loadInitialMessages()
  108. await waitForNextUpdate()
  109. // Send a message from the current user
  110. result.current.sendMessage('sent message from current user')
  111. const [sentMessageFromCurrentUser] = result.current.messages
  112. expect(sentMessageFromCurrentUser.contents).to.deep.equal([
  113. 'sent message from current user',
  114. ])
  115. act(() => {
  116. // Receive a message from another user.
  117. socket.emit('new-chat-message', {
  118. id: 'msg_1',
  119. content: 'new message from other user',
  120. timestamp: Date.now(),
  121. user: {
  122. id: 'another_fake_user',
  123. first_name: 'another_fake_user_first_name',
  124. email: 'another_fake@example.com',
  125. },
  126. })
  127. })
  128. const [, messageFromOtherUser] = result.current.messages
  129. expect(messageFromOtherUser.contents).to.deep.equal([
  130. 'new message from other user',
  131. ])
  132. // Receive a message from the current user
  133. socket.emit('new-chat-message', {
  134. id: 'msg_2',
  135. content: 'received message from current user',
  136. timestamp: Date.now(),
  137. user,
  138. })
  139. // Since the current user didn't just send a message, it is now shown
  140. const [, , receivedMessageFromCurrentUser] = result.current.messages
  141. expect(receivedMessageFromCurrentUser.contents).to.deep.equal([
  142. 'received message from current user',
  143. ])
  144. })
  145. })
  146. describe('loadInitialMessages', function () {
  147. beforeEach(function () {
  148. fetchMock.get('express:/project/:projectId/messages', [
  149. {
  150. id: 'msg_1',
  151. content: 'a message',
  152. user,
  153. timestamp: Date.now(),
  154. },
  155. ])
  156. })
  157. it('adds messages to the list', async function () {
  158. const { result, waitForNextUpdate } = renderChatContextHook({ user })
  159. result.current.loadInitialMessages()
  160. await waitForNextUpdate()
  161. expect(result.current.messages[0].contents).to.deep.equal(['a message'])
  162. })
  163. it("won't load messages a second time", async function () {
  164. const { result, waitForNextUpdate } = renderChatContextHook({ user })
  165. result.current.loadInitialMessages()
  166. await waitForNextUpdate()
  167. expect(result.current.initialMessagesLoaded).to.equal(true)
  168. // Calling a second time won't do anything
  169. result.current.loadInitialMessages()
  170. expect(fetchMock.calls()).to.have.lengthOf(1)
  171. })
  172. it('provides an error on failure', async function () {
  173. fetchMock.reset()
  174. fetchMock.get('express:/project/:projectId/messages', 500)
  175. const { result, waitForNextUpdate } = renderChatContextHook({ user })
  176. result.current.loadInitialMessages()
  177. await waitForNextUpdate()
  178. expect(result.current.error).to.exist
  179. expect(result.current.status).to.equal('error')
  180. })
  181. })
  182. describe('loadMoreMessages', function () {
  183. it('adds messages to the list', async function () {
  184. // Mock a GET request for an initial message
  185. fetchMock.getOnce('express:/project/:projectId/messages', [
  186. {
  187. id: 'msg_1',
  188. content: 'first message',
  189. user,
  190. timestamp: new Date('2021-03-04T10:00:00').getTime(),
  191. },
  192. ])
  193. const { result, waitForNextUpdate } = renderChatContextHook({ user })
  194. result.current.loadMoreMessages()
  195. await waitForNextUpdate()
  196. expect(result.current.messages[0].contents).to.deep.equal([
  197. 'first message',
  198. ])
  199. // The before query param is not set
  200. expect(getLastFetchMockQueryParam('before')).to.be.null
  201. })
  202. it('adds more messages if called a second time', async function () {
  203. // Mock 2 GET requests, with different content
  204. fetchMock
  205. .getOnce(
  206. 'express:/project/:projectId/messages',
  207. // Resolve a full "page" of messages (50)
  208. createMessages(50, user, new Date('2021-03-04T10:00:00').getTime())
  209. )
  210. .getOnce(
  211. 'express:/project/:projectId/messages',
  212. [
  213. {
  214. id: 'msg_51',
  215. content: 'message from second page',
  216. user,
  217. timestamp: new Date('2021-03-04T11:00:00').getTime(),
  218. },
  219. ],
  220. { overwriteRoutes: false }
  221. )
  222. const { result, waitForNextUpdate } = renderChatContextHook({ user })
  223. result.current.loadMoreMessages()
  224. await waitForNextUpdate()
  225. // Call a second time
  226. result.current.loadMoreMessages()
  227. await waitForNextUpdate()
  228. // The second request is added to the list
  229. // Since both messages from the same user, they are collapsed into the
  230. // same "message"
  231. expect(result.current.messages[0].contents).to.include(
  232. 'message from second page'
  233. )
  234. // The before query param for the second request matches the timestamp
  235. // of the first message
  236. const beforeParam = parseInt(getLastFetchMockQueryParam('before'), 10)
  237. expect(beforeParam).to.equal(new Date('2021-03-04T10:00:00').getTime())
  238. })
  239. it("won't load more messages if there are no more messages", async function () {
  240. // Mock a GET request for 49 messages. This is less the the full page size
  241. // (50 messages), meaning that there are no further messages to be loaded
  242. fetchMock.getOnce(
  243. 'express:/project/:projectId/messages',
  244. createMessages(49, user)
  245. )
  246. const { result, waitForNextUpdate } = renderChatContextHook({ user })
  247. result.current.loadMoreMessages()
  248. await waitForNextUpdate()
  249. expect(result.current.messages[0].contents).to.have.length(49)
  250. result.current.loadMoreMessages()
  251. expect(result.current.atEnd).to.be.true
  252. expect(fetchMock.calls()).to.have.lengthOf(1)
  253. })
  254. it('handles socket messages while loading', async function () {
  255. // Mock GET messages so that we can control when the promise is resolved
  256. let resolveLoadingMessages
  257. fetchMock.get(
  258. 'express:/project/:projectId/messages',
  259. new Promise(resolve => {
  260. resolveLoadingMessages = resolve
  261. })
  262. )
  263. const socket = new EventEmitter()
  264. const { result, waitForNextUpdate } = renderChatContextHook({
  265. user,
  266. socket,
  267. })
  268. // Start loading messages
  269. result.current.loadMoreMessages()
  270. // Mock message being received from the socket while the request is in
  271. // flight
  272. socket.emit('new-chat-message', {
  273. id: 'socket_msg',
  274. content: 'socket message',
  275. timestamp: Date.now(),
  276. user: {
  277. id: 'another_fake_user',
  278. first_name: 'another_fake_user_first_name',
  279. email: 'another_fake@example.com',
  280. },
  281. })
  282. // Resolve messages being loaded
  283. resolveLoadingMessages([
  284. {
  285. id: 'fetched_msg',
  286. content: 'loaded message',
  287. user,
  288. timestamp: Date.now(),
  289. },
  290. ])
  291. await waitForNextUpdate()
  292. // Although the loaded message was resolved last, it appears first (since
  293. // requested messages must have come first)
  294. const messageContents = result.current.messages.map(
  295. ({ contents }) => contents[0]
  296. )
  297. expect(messageContents).to.deep.equal([
  298. 'loaded message',
  299. 'socket message',
  300. ])
  301. })
  302. it('provides an error on failures', async function () {
  303. fetchMock.reset()
  304. fetchMock.get('express:/project/:projectId/messages', 500)
  305. const { result, waitForNextUpdate } = renderChatContextHook({ user })
  306. result.current.loadMoreMessages()
  307. await waitForNextUpdate()
  308. expect(result.current.error).to.exist
  309. expect(result.current.status).to.equal('error')
  310. })
  311. })
  312. describe('sendMessage', function () {
  313. beforeEach(function () {
  314. // Mock GET messages to return no messages and POST new message to be
  315. // successful
  316. fetchMock
  317. .get('express:/project/:projectId/messages', [])
  318. .postOnce('express:/project/:projectId/messages', 200)
  319. })
  320. it('optimistically adds the message to the list', function () {
  321. const { result } = renderChatContextHook({ user })
  322. result.current.sendMessage('sent message')
  323. expect(result.current.messages[0].contents).to.deep.equal([
  324. 'sent message',
  325. ])
  326. })
  327. it('POSTs the message to the backend', function () {
  328. const { result } = renderChatContextHook({ user })
  329. result.current.sendMessage('sent message')
  330. const [, { body }] = fetchMock.lastCall(
  331. 'express:/project/:projectId/messages',
  332. 'POST'
  333. )
  334. expect(JSON.parse(body)).to.deep.equal({ content: 'sent message' })
  335. })
  336. it("doesn't send if the content is empty", function () {
  337. const { result } = renderChatContextHook({ user })
  338. result.current.sendMessage('')
  339. expect(result.current.messages).to.be.empty
  340. expect(
  341. fetchMock.called('express:/project/:projectId/messages', {
  342. method: 'post',
  343. })
  344. ).to.be.false
  345. })
  346. it('provides an error on failure', async function () {
  347. fetchMock.reset()
  348. fetchMock
  349. .get('express:/project/:projectId/messages', [])
  350. .postOnce('express:/project/:projectId/messages', 500)
  351. const { result, waitForNextUpdate } = renderChatContextHook({ user })
  352. result.current.sendMessage('sent message')
  353. await waitForNextUpdate()
  354. expect(result.current.error).to.exist
  355. expect(result.current.status).to.equal('error')
  356. })
  357. })
  358. describe('unread messages', function () {
  359. beforeEach(function () {
  360. // Mock GET messages to return no messages
  361. fetchMock.get('express:/project/:projectId/messages', [])
  362. })
  363. it('increments unreadMessageCount when a new message is received', function () {
  364. const socket = new EventEmitter()
  365. const { result } = renderChatContextHook({ user, socket })
  366. // Receive a new message from the socket
  367. socket.emit('new-chat-message', {
  368. id: 'msg_1',
  369. content: 'new message',
  370. timestamp: Date.now(),
  371. user,
  372. })
  373. expect(result.current.unreadMessageCount).to.equal(1)
  374. })
  375. it('resets unreadMessageCount when markMessagesAsRead is called', function () {
  376. const socket = new EventEmitter()
  377. const { result } = renderChatContextHook({ user, socket })
  378. // Receive a new message from the socket, incrementing unreadMessageCount
  379. // by 1
  380. socket.emit('new-chat-message', {
  381. id: 'msg_1',
  382. content: 'new message',
  383. timestamp: Date.now(),
  384. user,
  385. })
  386. result.current.markMessagesAsRead()
  387. expect(result.current.unreadMessageCount).to.equal(0)
  388. })
  389. })
  390. })
  391. function renderChatContextHook(props) {
  392. return renderHook(() => useChatContext(), {
  393. // Wrap with ChatContext.Provider (and the other editor context providers)
  394. // eslint-disable-next-line react/display-name
  395. wrapper: ({ children }) => (
  396. <ChatProviders {...props}>{children}</ChatProviders>
  397. ),
  398. })
  399. }
  400. function createMessages(number, user, timestamp = Date.now()) {
  401. return Array.from({ length: number }, (_m, idx) => ({
  402. id: `msg_${idx + 1}`,
  403. content: `message ${idx + 1}`,
  404. user,
  405. timestamp,
  406. }))
  407. }
  408. /*
  409. * Get query param by key from the last fetchMock response
  410. */
  411. function getLastFetchMockQueryParam(key) {
  412. const { url } = fetchMock.lastResponse()
  413. const { searchParams } = new URL(url, 'https://www.overleaf.com')
  414. return searchParams.get(key)
  415. }